Эта статья — не ревью чужого кода и не пересказ абстрактных паттернов. Это практическое описание того, как я подхожу к проектированию сетевого слоя, какие решения считаю удачными, какие — опасными, и почему.
Основа текста — реальный подход к построению сети в production iOS‑приложении: с явнойEndpointPolicy,RequestContext, interceptor‑pipeline, безопасным логированием, отдельной обработкой refresh flow, snapshot‑first чтением и выделенным transport для долгих upload‑сценариев.
Мой главный тезис простой: сетевой слой — это не «место, где отправляются запросы», а инфраструктура приложения. URLRequest и URLSessionConfiguration в Foundation уже работают как объекты, которые несут не только URL, но и правила поведения запроса; Swift Concurrency даёт структурированную асинхронность и безопасную модель доступа к разделяемому состоянию через actors. На практике это значит, что retry, auth, timeout, cache, logging и metrics лучше проектировать как часть контракта, а не как случайный набор if внутри одного большого send().
Если сжать статью до одного абзаца, получится так:
Я начинаю не с URLSession, а с вопросов:
Кто имеет право знать о transport, где живёт policy, кто отвечает за retry, как не утечь токенами в логах, где source of truth для UI и как выяснить, что именно сломалось
в production.
Всё остальное — код вокруг этих решений.
Введение
Когда проект маленький, сеть почти всегда выглядит невинно. Есть экран, есть URLSession, есть один NetworkService, в который складывается «чуть‑чуть auth», «чуть‑чуть retry», «ещё немного логов», а потом туда же прилетает refresh token, upload, аналитика, debug‑print, обработка 401, ручной dispatch и бесконечные исключения для «вот этого особого endpoint‑а».
Проблема не в том, что такой код становится длинным. Проблема в том, что он начинает угадывать поведение системы.
Сетевой слой очень быстро превращается в God Object, если он одновременно:
-
Собирает
URLRequest, -
Знает про токены,
-
Сам решает, когда retry‑ить,
-
Сам маппит ошибки в UI‑смыслы,
-
Сам пишет логи,
-
Сам меряет latency,
-
Сам понимает, что считать кешем,
-
И ещё где‑то по пути пытается решить навигацию.
Я проектирую сетевой слой иначе: не как «обёртку над URLSession», а как систему договоров. Сначала я определяю, какие правила должны быть едиными. Потом выстраиваю границы. И уже после этого пишу transport.
Современный стек iOS этому только помогает. Foundation даёт URLSession, URLRequest, background sessions и URLSessionTaskMetrics; Swift даёт async/await и actors для безопасной работы с разделяемым состоянием. То есть инструменты для чистой архитектуры в платформе уже есть — вопрос только в том, чтобы не свести их к «одному универсальному клиенту со 150 строками switch».
Ниже — требования, с которых я начинаю почти любой сетевой слой.
|
Что нужно |
Что это значит на практике |
|---|---|
|
Предсказуемость |
Один и тот же тип запроса всегда проходит одинаковый путь |
|
Явные правила |
Auth, retry, timeout, logging и cache не угадываются по path |
|
Безопасность |
Токены не живут в |
|
Наблюдаемость |
У любого запроса есть request id, snapshots и метрики |
|
Изоляция от UI |
|
|
Поддержка плохой сети |
Есть стратегия для offline, retry, refresh и background upload |
|
Масштабируемость |
REST и realtime не смешиваются там, где это не нужно |
Самое важное здесь — поведение должно быть описано явно. RFC 9110 различает безопасные и идемпотентные методы и отдельно предупреждает: автоматически повторять неидемпотентный запрос нельзя, если клиент не знает его семантику или не может доказать, что исходный запрос не был применён. Из этого для меня следует простой вывод: retry нельзя строить на догадке «ну это же GET» или «ну это же POST». Повторяемость должна лежать рядом с endpoint‑ом как часть контракта.
Чтобы показать, зачем всё это нужно, достаточно сравнить плохой и хороший старт проекта.
|
Подход |
Почему выглядит удобно |
Чем заканчивается |
|---|---|---|
|
Один |
Быстро начать |
God Object, дубли, скрытые правила |
|
Direct network из |
Меньше файлов |
UI знает про URL, токены и транспорт |
|
Retry по HTTP‑методу |
Кажется «умным» |
Ошибочные повторы, дубли записей, сложно дебажить |
|
|
Быстро видно проблему |
Утечки токенов, телефонов, payload |
|
Данные только из сети |
Меньше локальной логики |
Мигание UI, лишние запросы, слабый offline |
Именно из этих наблюдений и рождается архитектура ниже.
Требования и общая архитектура
Когда я проектирую сетевой слой, я почти никогда не начинаю с transport. Я начинаю с графа зависимостей: кто кого знает.
Вот базовая схема, которой я придерживаюсь:

Смысл этой схемы очень простой.
|
Слой |
За что отвечает |
|---|---|
|
View |
Показывает состояние и отправляет пользовательские действия |
|
Store / ViewModel |
Держит state и запускает сценарий |
|
UseCase |
Описывает бизнес‑операцию |
|
Repository |
Решает, откуда взять данные: сеть, snapshot, локальная БД |
|
RemoteDataSource |
Хранит знание о конкретных REST‑вызовах |
|
Endpoint |
Описывает один запрос и его policy |
|
NetworkClient |
Запускает pipeline |
|
Pipeline |
Применяет инфраструктурные правила |
|
Transport |
Фактически отправляет запрос |
|
URLSession |
Системный сетевой стек |
Такой граф специально не даёт ViewModel залезть в URLSession. И это не эстетика ради красоты. Это защита от хаоса.
Плохой вариант выглядит так:
final class ProfileViewModel: ObservableObject { @Published private(set) var profile: ProfileViewData? func onAppear() { Task { let url = URL(string: "https://api.example.com/profile/me")! var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let (data, _) = try await URLSession.shared.data(for: request) let dto = try JSONDecoder().decode(ProfileDTO.self, from: data) self.profile = ProfileViewData(name: dto.fullName) } }}
На первом экране это «нормально». На пятом экране у вас уже:
-
дублируются headers,
-
ошибки маппятся по‑разному,
-
кто‑то логирует body целиком,
-
кто‑то retry‑ит руками,
-
кто‑то для
401делает refresh сам, -
UI начинает знать про transport‑модель.
Хороший вариант тяжелее на старте, но почти всегда дешевле в поддержке:
protocol NetworkClientProtocol { func request<E: EndpointProtocol>(_ endpoint: E) async throws -> E.Response}protocol ProfileRemoteDataSource { func loadProfile() async throws -> ProfileDTO}final class DefaultProfileRemoteDataSource: ProfileRemoteDataSource { private let client: NetworkClientProtocol init(client: NetworkClientProtocol) { self.client = client } func loadProfile() async throws -> ProfileDTO { try await client.request(LoadProfileEndpoint()) }}protocol ProfileRepository { func loadProfile() async throws -> Profile}final class DefaultProfileRepository: ProfileRepository { private let remote: ProfileRemoteDataSource private let mapper: ProfileMapper init(remote: ProfileRemoteDataSource, mapper: ProfileMapper) { self.remote = remote self.mapper = mapper } func loadProfile() async throws -> Profile { let dto = try await remote.loadProfile() return mapper.map(dto) }}
Здесь каждый слой знает только свою часть работы. Это особенно важно, когда transport начинает жить по собственным правилам: background upload, task metrics, waits‑for‑connectivity, refresh flow, transient offline и так далее.
Apple прямо описывает URLSession как общий API для загрузки и отправки данных по URL,
а URLSessionConfiguration — как объект, который задаёт поведение и policies для сессии.
Для меня это ещё одно подтверждение: правила работы должны жить в инфраструктуре,
а не быть размазаны по feature‑коду.
Из практических трудностей здесь почти всегда всплывает одна и та же: команда хочет «быстро сходить в сеть» из presentation. Решается это только жёсткой границей. Я обычно даю feature‑разработчику точки расширения, но не даю трогать network core напрямую.
|
Что можно делать в feature |
Что нельзя делать |
|---|---|
|
Создать новый endpoint |
Добавлять |
|
Описать DTO |
Добавлять |
|
Написать |
Делать retry руками в repository |
|
Собрать mapper |
Логировать raw body с секретами |
|
Позвать use case |
Менять pipeline под один экран |
Для меня это и есть «общая архитектура» сетевого слоя: не объект, а система ограничений, через которые потом удобно жить.
Endpoint и policy
Что делает endpoint и почему он не равен URL
Endpoint для меня — это не просто path + method + body. Это контракт запроса.
Если endpoint знает только URL, то кто‑то другой начинает угадывать:
-
Нужен ли токен,
-
Можно ли retry,
-
Какой timeout,
-
Надо ли логировать body,
-
Нужен ли idempotency key,
-
Можно ли читать snapshot или transport‑cache.
Я считаю это корневой архитектурной ошибкой, поэтому почти всегда начинаю с протокола примерно такого вида:
import Foundationprotocol EndpointProtocol { associatedtype Response: Decodable var baseURL: URL { get } var path: String { get } var method: HTTPMethod { get } var headers: [String: String] { get } var query: [URLQueryItem] { get } var body: RequestBody? { get } var contentType: ContentType { get } var acceptType: AcceptType { get } var policy: EndpointPolicy { get } /// Для особых flow, где HTTP status сам по себе является частью сценария. var returnsRawResponseOnAnyStatus: Bool { get }}extension EndpointProtocol { var headers: [String: String] { [:] } var query: [URLQueryItem] { [] } var body: RequestBody? { nil } var contentType: ContentType { .json } var acceptType: AcceptType { .json } var returnsRawResponseOnAnyStatus: Bool { false }}
Как выглядит policy
struct EndpointPolicy { let auth: AuthPolicy let cache: CachePolicyProtocol let retry: RetryPolicyProtocol let timeout: TimeInterval? let logging: LoggingPolicy let idempotency: IdempotencyPolicy}enum AuthPolicy { case none case bearer var requiresAuthentication: Bool { switch self { case .none: return false case .bearer: return true } }}enum LoggingPolicy { case disabled case safe case debugSafe}enum IdempotencyPolicy { case none case automaticHeader case explicit(String)}
Идея здесь простая: клиент не должен угадывать поведение endpoint‑а по имени, методу или path.
Плохой вариант:
if endpoint.method == .get { // Ну GET же безопасный, значит можно retry}if endpoint.path.contains("auth") { // Скорее всего токен не нужен}if endpoint.path.contains("upload") { // Скорее всего нужен длинный timeout}
Хороший вариант:
extension EndpointPolicy { static func authorizedRead( cache: CachePolicyProtocol = NoCachePolicy(), retry: RetryPolicyProtocol = DefaultRetryPolicy(), timeout: TimeInterval? = nil, logging: LoggingPolicy = .debugSafe ) -> EndpointPolicy { EndpointPolicy( auth: .bearer, cache: cache, retry: retry, timeout: timeout, logging: logging, idempotency: .none ) } static func authorizedWrite( retry: RetryPolicyProtocol = NoRetryPolicy(), timeout: TimeInterval? = nil, logging: LoggingPolicy = .debugSafe, idempotency: IdempotencyPolicy = .none ) -> EndpointPolicy { EndpointPolicy( auth: .bearer, cache: NoCachePolicy(), retry: retry, timeout: timeout, logging: logging, idempotency: idempotency ) } static func authorizedUpload() -> EndpointPolicy { EndpointPolicy( auth: .bearer, cache: NoCachePolicy(), retry: UploadRetryPolicy(), timeout: 120, logging: .debugSafe, idempotency: .automaticHeader ) } static func publicPolling() -> EndpointPolicy { EndpointPolicy( auth: .none, cache: DefaultCachePolicy(), retry: PollingRetryPolicy(), timeout: 10, logging: .safe, idempotency: .none ) } static func authRefresh() -> EndpointPolicy { EndpointPolicy( auth: .none, cache: NoCachePolicy(), retry: RefreshRetryPolicy(), timeout: 15, logging: .disabled, idempotency: .none ) }}
Где здесь best practice, а где просто удобство
В HTTP есть безопасные и идемпотентные методы. RFC 9110 прямо говорит, что безопасными являются GET, HEAD, OPTIONS и TRACE, а идемпотентными — PUT, DELETE и безопасные методы. Но тот же RFC отдельно отмечает: клиент не должен автоматически повторять неидемпотентный запрос, если только не знает, что семантика конкретного вызова безопасна для повторения. Для меня это прямое обоснование того, почему RetryPolicy должен лежать рядом с endpoint‑ом, а не прятаться внутри «умного» retry resolver.
Отдельная история — bearer auth. RFC 6750 рекомендует передавать access token в Authorization: Bearer ... и отдельно предупреждает, что способ через query parameter небезопасен и не рекомендован, в том числе потому, что URL с токеном с высокой вероятностью окажется в логах. Из этого у меня следует жёсткое правило: никаких токенов в query, только header или, в очень специальных сценариях, специально оговорённый body flow.
Для записывающих операций я также отделяю вопрос «можно ли повторять» от вопроса «какой у нас HTTP‑метод». Это хорошо сочетается с практикой Idempotency-Key: IETF draft для этого заголовка как раз описывает способ сделать POST и PATCH более отказоустойчивыми при повторах. В моём подходе это выглядит так: если endpoint пишет данные и может быть повторён, он получает свою IdempotencyPolicy, а ключ генерируется один раз на жизненный цикл запроса, а не заново на каждый retry.
Трудность, с которой я почти всегда сталкиваюсь
На живом проекте редко удаётся сразу прийти к идеальной модели. У меня был промежуточный этап, когда рядом жили:
-
старые отдельные поля вроде
requiresAuthentication,retryPolicy,timeout, -
и новая unified
policy.
Это очень типичный миграционный долг. Он опасен тем, что одинаковый endpoint может быть описан в двух разных местах, а команда не всегда понимает, что считается источником правды.
Решение здесь не технически сложное, но дисциплинарно обязательное:
|
Проблема |
Как выглядит |
Как исправлять |
|---|---|---|
|
Двойной источник правды |
|
Ввести жёсткий приоритет |
|
Скрытый off‑by‑one в retry |
|
Переименовать в |
|
Магические helper‑ы |
Название красивое, семантика неочевидна |
Документировать preset‑ы на уровне кода |
|
Policy начинает разрастаться без контроля |
Каждый кейс — новый enum case |
Часть специальных правил выносить в strategy/protocol |
Вот тот самый неприятный пример с retry‑семантикой, который я теперь стараюсь закрывать сразу:
protocol RetryPolicyProtocol { /// Сколько повторов можно сделать после первого выполнения. var maxRetries: Int { get } func shouldRetry( request: URLRequest, response: HTTPURLResponse?, data: Data?, error: Error?, retryIndex: Int ) -> Bool func delay( response: HTTPURLResponse?, retryIndex: Int ) -> TimeInterval}
maxRetries намного понятнее, чем maxAttempts. Потому что noRetry — это ноль повторов, а не ноль запусков.
Когда «HTTP‑ошибка» не должна падать как ошибка
Есть особый класс сценариев, где 409, 410, 429, 503 и похожие статусы — это не «просто ошибка transport‑а», а шаг бизнес‑потока. Например:
-
polling статуса внешней верификации,
-
long‑running auth flow,
-
operation pending,
-
rate‑limited intermediate state.
Для этого я выделяю отдельный флаг:
struct VerificationStatusEndpoint: EndpointProtocol { typealias Response = VerificationStatusPayload let sessionId: String var baseURL: URL { Environment.apiBaseURL } var path: String { "/auth/verification/status" } var method: HTTPMethod { .get } var query: [URLQueryItem] { [URLQueryItem(name: "sessionId", value: sessionId)] } var policy: EndpointPolicy { .publicPolling() } /// Здесь HTTP status — часть flow, поэтому raw response нужен всегда. var returnsRawResponseOnAnyStatus: Bool { true }}
И это решение мне нравится намного больше, чем «у ErrorMappingInterceptor есть список path‑ов, которые не надо маппить в ошибку».
RequestContext, pipeline, interceptors и error mapping
Что делает RequestContext и почему без него быстро становится больно
Если endpoint — это контракт вызова, то RequestContext — это паспорт конкретного выполнения этого вызова.
Мне нужен один объект, в котором живёт всё, что относится не к типу запроса вообще, а к его текущей жизни:
-
request id,
-
policy,
-
сколько уже было retry,
-
стабильный idempotency key,
-
snapshots,
-
task,
-
ручные метрики,
-
системные task metrics,
-
сравнение ручных и системных измерений.
Примерно так:
import Foundationfinal class RequestContext { let requestId: String let policy: EndpointPolicy let startedAt: ContinuousClock.Instant let idempotencyKey: String? var retryCount: Int = 0 var urlSessionTask: URLSessionTask? var requestSnapshot: RequestSnapshot? var responseSnapshot: ResponseSnapshot? var errorSnapshot: ErrorSnapshot? var manualMetrics: ManualMetricsSnapshot? var taskMetrics: URLSessionTaskMetrics? var metricsComparison: MetricsComparisonReport? init(policy: EndpointPolicy) { self.requestId = UUID().uuidString self.policy = policy self.startedAt = ContinuousClock().now switch policy.idempotency { case .none: self.idempotencyKey = nil case .automaticHeader: self.idempotencyKey = UUID().uuidString case .explicit(let value): self.idempotencyKey = value } }}
Ключевое решение здесь такое: request id и idempotency key создаются один раз на весь жизненный цикл запроса, а не на каждый retry. Это особенно важно для записывающих операций: повтор должен быть продолжением исходного вызова, а не новой логической командой.
Как работает pipeline
Я предпочитаю pipeline одному большому методу send(), потому что pipeline позволяет делать технические вещи последовательно и отдельно друг от друга.

В коде это обычно выглядит так:
struct ResponseEnvelope { let data: Data let response: HTTPURLResponse}protocol NetworkInterceptor { func intercept( _ request: URLRequest, context: RequestContext, next: @escaping @Sendable (URLRequest, RequestContext) async throws -> ResponseEnvelope ) async throws -> ResponseEnvelope}protocol NetworkTransport { func execute( _ request: URLRequest, context: RequestContext ) async throws -> ResponseEnvelope}
А сам клиент выглядит примерно так:
final class NetworkClient: NetworkClientProtocol { private let requestBuilder: RequestBuilder private let transport: NetworkTransport private let interceptors: [NetworkInterceptor] private let decoder: JSONDecoder private let errorMapper: ErrorMapper init( requestBuilder: RequestBuilder, transport: NetworkTransport, interceptors: [NetworkInterceptor], decoder: JSONDecoder = JSONDecoder(), errorMapper: ErrorMapper ) { self.requestBuilder = requestBuilder self.transport = transport self.interceptors = interceptors self.decoder = decoder self.errorMapper = errorMapper } func request<E: EndpointProtocol>(_ endpoint: E) async throws -> E.Response { let context = RequestContext(policy: endpoint.policy) let request = try requestBuilder.build(from: endpoint, context: context) let envelope = try await executePipeline( request, context: context ) if endpoint.returnsRawResponseOnAnyStatus == false { try errorMapper.validateHTTP(response: envelope.response, data: envelope.data) } do { return try decoder.decode(E.Response.self, from: envelope.data) } catch let decodingError as DecodingError { throw NetworkError.decoding(decodingError) } } private func executePipeline( _ request: URLRequest, context: RequestContext ) async throws -> ResponseEnvelope { let terminal: @Sendable (URLRequest, RequestContext) async throws -> ResponseEnvelope = { [transport] request, context in try await transport.execute(request, context: context) } let chain = interceptors.reversed().reduce(terminal) { next, interceptor in { request, context in try await interceptor.intercept(request, context: context, next: next) } } return try await chain(request, context) }}
Почему interceptor‑цепочка лучше «умного клиента»
Потому что каждая техническая обязанность получает своё место.
|
Interceptor |
Что он делает |
|---|---|
|
RequestIdInterceptor |
Добавляет request id в headers и metadata |
|
EndpointPolicyInterceptor |
Применяет timeout, idempotency, policy‑driven headers |
|
AuthInterceptor |
Подставляет bearer token |
|
RetryInterceptor |
Повторяет запрос по правилам policy |
|
LoggingInterceptor |
Пишет безопасный лог и snapshots |
|
MetricsInterceptor |
Снимает manual metrics и собирает |
|
ErrorMappingInterceptor |
Маппит transport и HTTP в |
Это намного безопаснее, чем один гигантский send() на 200 строк.
Auth interceptor и refresh flow
С bearer token есть два правила, которые я для себя считаю обязательными:
-
токен добавляется централизованно,
-
refresh живёт в инфраструктуре, а не дублируется по repository.
Так выглядит типичный auth interceptor:
final class AuthInterceptor: NetworkInterceptor { private let tokenManager: TokenManager init(tokenManager: TokenManager) { self.tokenManager = tokenManager } func intercept( _ request: URLRequest, context: RequestContext, next: @escaping @Sendable (URLRequest, RequestContext) async throws -> ResponseEnvelope ) async throws -> ResponseEnvelope { var request = request if context.policy.auth.requiresAuthentication { let token = try await tokenManager.validAccessToken() request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let firstResponse = try await next(request, context) guard firstResponse.response.statusCode == 401, context.policy.auth.requiresAuthentication, context.retryCount == 0 else { return firstResponse } try await tokenManager.forceRefreshTokens() context.retryCount += 1 var retryRequest = request let freshToken = try await tokenManager.validAccessToken() retryRequest.setValue("Bearer \(freshToken)", forHTTPHeaderField: "Authorization") return try await next(retryRequest, context) }}
Сама защита от refresh storm удобно ложится на actor. Swift actors нужны как раз для безопасного доступа к разделяемому изменяемому состоянию между конкурентным кодом. В случае токенов это почти идеальный инструмент.
actor TokenManager { private let tokenStore: TokenStore private let refreshService: RefreshService private var refreshTask: Task<Void, Error>? init(tokenStore: TokenStore, refreshService: RefreshService) { self.tokenStore = tokenStore self.refreshService = refreshService } func validAccessToken() async throws -> String { if let token = try tokenStore.readAccessToken(), tokenIsStillValid(token) { return token } try await forceRefreshTokens() guard let refreshed = try tokenStore.readAccessToken() else { throw NetworkError.unauthorized } return refreshed } func forceRefreshTokens() async throws { if let refreshTask { return try await refreshTask.value } let task = Task { let pair = try await refreshService.refresh() try tokenStore.writeAccessToken(pair.accessToken) try tokenStore.writeRefreshToken(pair.refreshToken) } refreshTask = task defer { refreshTask = nil } try await task.value }}
Здесь важна не конкретная реализация, а принцип: один refresh на всех, остальные запросы ждут тот же результат.
Retry interceptor и server‑driven backoff
Retry я считаю одной из самых опасных «маленьких возможностей». Чаще всего проблемы начинаются не там, где retry отсутствует, а там, где retry «слишком умный».
Вот такое решение выглядит логично, но на деле очень хрупкое:
final class BadRetryLogic { func shouldRetry(_ request: URLRequest, error: Error) -> Bool { request.httpMethod == "GET" }}
Почему это плохо:
-
не каждый
GETможно повторять бездумно, -
некоторые
POSTможно повторять только с idempotency‑key, -
polling flow должен учитывать серверный интервал,
-
повторять cancelled task нельзя,
-
повторять upload без стабильного ключа опасно.
Нормальная retry policy у меня устроена так:
protocol RetryPolicyProtocol { var maxRetries: Int { get } func shouldRetry( response: HTTPURLResponse?, data: Data?, error: Error?, retryIndex: Int ) -> Bool func delay( response: HTTPURLResponse?, retryIndex: Int ) -> TimeInterval}
И пример default policy:
struct DefaultRetryPolicy: RetryPolicyProtocol { let maxRetries: Int let fallbackDelay: TimeInterval init(maxRetries: Int = 2, fallbackDelay: TimeInterval = 0.5) { self.maxRetries = maxRetries self.fallbackDelay = fallbackDelay } func shouldRetry( response: HTTPURLResponse?, data: Data?, error: Error?, retryIndex: Int ) -> Bool { guard retryIndex < maxRetries else { return false } if let urlError = error as? URLError { switch urlError.code { case .timedOut, .networkConnectionLost, .cannotFindHost, .cannotConnectToHost: return true default: return false } } if let status = response?.statusCode { return status == 503 || status == 429 } return false } func delay( response: HTTPURLResponse?, retryIndex: Int ) -> TimeInterval { if let header = response?.value(forHTTPHeaderField: "Retry-After"), let seconds = TimeInterval(header) { return seconds } return fallbackDelay * pow(2, Double(retryIndex)) }}
Здесь уже видно другую философию:
-
retry зависит от policy endpoint‑а,
-
server signal имеет приоритет,
-
Retry-Afterуважается, -
логика записи и чтения не смешивается.
Это хорошо согласуется и со стандартами. RFC 9110 описывает Retry-After как заголовок, который говорит, сколько ждать перед повторным обращением, а RFC 6585 отдельно вводит 429 Too Many Requests и говорит, что ответ может содержать Retry-After с указанием паузы.
Logging и snapshots
Сетевой лог без redaction — это не observability, а потенциальная утечка. RFC 6750 отдельно подчёркивает, что bearer tokens нужно защищать и в storage, и при transport.
Из этого для меня логически следует ещё одно правило: если токен может оказаться
в обычном сетевом логе, значит логирование спроектировано неверно.
Поэтому вместо print(request) я делаю два безопасных снимка.
struct RequestSnapshot { let requestId: String let method: String let path: String let headers: [String: String] let bodyPreview: String?}struct ResponseSnapshot { let requestId: String let statusCode: Int let duration: TimeInterval let headers: [String: String] let bodyPreview: String?}struct ErrorSnapshot { let requestId: String let domain: String let code: Int let description: String}
И redactor:
enum NetworkRedactor { static func redactHeaders(_ headers: [String: String]) -> [String: String] { var headers = headers if headers["Authorization"] != nil { headers["Authorization"] = "Bearer ***redacted***" } if headers["X-Refresh-Token"] != nil { headers["X-Refresh-Token"] = "***redacted***" } return headers } static func redactBodyPreview(_ body: Data?) -> String? { guard let body, !body.isEmpty else { return nil } let raw = String(decoding: body, as: UTF8.self) return raw .replacingOccurrences(of: "\"password\":\"", with: "\"password\":\"***") .replacingOccurrences(of: "\"phone\":\"", with: "\"phone\":\"***") .prefix(500) .description }}
OSLog и Logger в Apple‑стеке хороши тем, что позволяют писать структурированные сообщения с subsystem и category; а URLSessionTaskMetrics дают системный мета‑уровень поверх наших ручных замеров. Именно поэтому я люблю хранить в RequestContext и manual timing, и taskMetrics, а потом сводить их в один debug‑report.
Error mapping
Если UI получает URLError.notConnectedToInternet, DecodingError.typeMismatch и голый HTTPURLResponse.statusCode == 429, значит я слишком высоко протащил детали transport‑а.
Я разделяю ошибки минимум на три уровня:
enum NetworkError: Error { case noInternet case timeout case unauthorized case forbidden case tooManyRequests(retryAfter: TimeInterval?) case server(statusCode: Int) case decoding(DecodingError) case transport(URLError)}enum DomainError: Error { case sessionExpired case temporaryUnavailable case profileIncomplete case actionThrottled(retryAfter: TimeInterval?)}enum FeatureError: Error { case banner(String) case inlineValidation(String)}
И mapper:
struct ErrorMapper { func validateHTTP(response: HTTPURLResponse, data: Data) throws { switch response.statusCode { case 200 ..< 300: return case 401: throw NetworkError.unauthorized case 403: throw NetworkError.forbidden case 429: let retryAfter = Self.parseRetryAfter(from: response) throw NetworkError.tooManyRequests(retryAfter: retryAfter) case 500 ... 599: throw NetworkError.server(statusCode: response.statusCode) default: throw NetworkError.server(statusCode: response.statusCode) } } private static func parseRetryAfter(from response: HTTPURLResponse) -> TimeInterval? { guard let raw = response.value(forHTTPHeaderField: "Retry-After") else { return nil } return TimeInterval(raw) }}
А уже repository решает, что из этого значит для бизнеc‑сценария:
extension DomainError { static func from(_ error: Error) -> DomainError { switch error { case NetworkError.unauthorized: return .sessionExpired case NetworkError.tooManyRequests(let retryAfter): return .actionThrottled(retryAfter: retryAfter) case NetworkError.server: return .temporaryUnavailable default: return .temporaryUnavailable } }}
Вот здесь очень хорошо видно, где кончается transport и начинается предметная область.
Session management, snapshot‑first, observability и безопасность
Session management
Токены — это не просто «ещё одна пара строк». Apple описывает Keychain Services как механизм для хранения небольших пользовательских данных в зашифрованной базе, и это как раз то место, куда должны попадать access token и refresh token. Не UserDefaults, не файл на диске, не «временное поле в SessionSingleton».
Я почти всегда разделяю три уровня хранения:
|
Данные |
Где живут |
Почему |
|---|---|---|
|
|
Keychain |
Секреты, которые нельзя светить |
|
|
локальное app‑хранилище |
Быстрый старт и source of truth |
|
transport cache |
|
Ускорение, но не бизнес‑истина |
Базовый интерфейс такой:
protocol TokenStore { func readAccessToken() throws -> String? func writeAccessToken(_ token: String?) throws func readRefreshToken() throws -> String? func writeRefreshToken(_ token: String?) throws func clear() throws}
И отдельно — session snapshot:
struct UserSessionSnapshot: Codable { let userId: String let displayName: String let avatarVersion: String? let profileVersion: Int let savedAt: Date}protocol SessionSnapshotStore { func load() throws -> UserSessionSnapshot? func save(_ snapshot: UserSessionSnapshot) throws func clear() throws}
Плохой подход здесь тоже очень типичный:
|
Плохое решение |
Последствия |
|---|---|
|
Токены в |
Слабая модель безопасности |
|
Профиль только в runtime memory |
Потеря состояния при релончe |
|
Каждый экран грузит профиль сам |
Дубли запросов и мигающий UI |
|
Refresh flow в каждом repository |
Дубли и гонки |
Snapshot‑first и offline
На уровне UI я почти всегда предпочитаю snapshot‑first. Это значит, что экран сначала показывает локально сохранённое состояние, а потом спокойно инициирует refresh.
Это похоже на идею stale-while-revalidate: вернуть текущее состояние быстро и обновить его в фоне. В HTTP такая стратегия и её соседка stale-if-error описаны в RFC 5861, а общие правила свежести и устаревания ответов — в RFC 9111. На уровне приложения я не копирую эти RFC буквально, но использую ту же логику: сначала быстро отдать известное состояние, потом тихо перепроверить сетью.

Repository при таком подходе обычно выглядит так:
final class ProfileRepository { private let remote: ProfileRemoteDataSource private let mapper: ProfileMapper private let snapshotStore: SessionSnapshotStore init( remote: ProfileRemoteDataSource, mapper: ProfileMapper, snapshotStore: SessionSnapshotStore ) { self.remote = remote self.mapper = mapper self.snapshotStore = snapshotStore } func cachedProfile() throws -> Profile? { guard let snapshot = try snapshotStore.load() else { return nil } return Profile( id: snapshot.userId, name: snapshot.displayName, avatarVersion: snapshot.avatarVersion ) } func refreshProfile() async throws -> Profile { let dto = try await remote.loadProfile() let profile = mapper.map(dto) let snapshot = UserSessionSnapshot( userId: profile.id, displayName: profile.name, avatarVersion: profile.avatarVersion, profileVersion: profile.version, savedAt: Date() ) try snapshotStore.save(snapshot) return profile }}
Почему я не считаю URLCache достаточным ответом? Потому что Apple описывает URLCache как кэш HTTP‑ответов на URL‑запросы. Это полезно для transport‑уровня, но это не тот же самый уровень, что бизнесовый snapshot пользователя, чатов или настроек. URLCache ускоряет запрос. Snapshot даёт UI source of truth. Эти вещи полезно не путать. citeturn6search2turn6search9
Connectivity и реальное состояние сети
Я не люблю один флаг isConnected. Он врет слишком часто.
NWPathMonitor хорош тем, что умеет наблюдать изменения network path, но он не отвечает на вопрос «жив ли ваш backend» и тем более не знает, что происходит с websocket или с вашим auth state. Поэтому я предпочитаю отдельную модель состояния:
enum ConnectivityStatus: Equatable { case online case waitingForConnectivity case noInternet case backendUnavailable case realtimeDisconnected}
NWPathMonitor даёт нижний уровень сигнала — есть ли доступная сеть и как меняется путь. Дальше уже приложение должно само отличать «вообще нет интернета» от «сервер отвечает 503» и от «REST жив, но realtime отвалился». citeturn5search3turn6search1turn6search18
Observability
Если нет логов и метрик, вы не управляете системой — вы гадаете.
Мой минимальный набор для сетевого слоя такой:
|
Что собираю |
Зачем |
|---|---|
|
request id |
связать цепочку логов, retries и ошибок |
|
duration |
видеть медленные вызовы |
|
status code |
ловить всплески |
|
retry count |
понимать нестабильность канала или backend |
|
payload size |
находить тяжёлые ответы |
|
decoding failures |
быстро видеть несовпадение контрактов |
|
|
понять DNS, connect, TLS, transfer |
|
redacted snapshots |
быстро дебажить без утечек |
Кодово это выглядит просто:
protocol NetworkLogger { func log(_ message: String)}protocol NetworkMetricsSink { func record(_ event: NetworkMetricEvent)}struct NetworkMetricEvent { let requestId: String let path: String let statusCode: Int? let duration: TimeInterval let retryCount: Int}
А адаптер к Logger может выглядеть так:
import OSLogstruct OSLogNetworkLogger: NetworkLogger { private let logger = Logger(subsystem: "com.example.app", category: "network") func log(_ message: String) { logger.debug("\(message, privacy: .public)") }}
Apple рекомендует Logger/OSLog для структурированного логирования,
а URLSessionTaskMetrics и delegate‑метод urlSession(_:task:didFinishCollecting:) дают системные метрики по задаче. То есть платформенный стек уже закрывает половину observability‑задания — важно только вывести это в свой контекст и не потерять по дороге.
Upload, media и background transfer
Одна из частых ошибок — пытаться прогнать upload большого файла через тот же путь, что и обычный JSON‑запрос.
Я давно перестал так делать. Для долгих или хрупких медиа‑сценариев я предпочитаю выделять отдельный transport и staging‑слой:
-
foreground transport для быстрых и управляемых загрузок,
-
background transport для больших файлов и нестабильной сети,
-
staging store для временных файлов,
-
отдельную очистку просроченных transfer‑артефактов.
Apple отдельно документирует background sessions в URLSession и URLSessionConfiguration.background(...): такие конфигурации нужны именно для upload/download задач, которые должны продолжаться вне foreground‑жизни приложения; отдельная background configuration делает это системной задачей, а не «пока держится текущий экран».
Пример интерфейса:
enum UploadMode { case foreground case background}struct UploadFile { let fileURL: URL let mimeType: String let filename: String}protocol MediaUploadClient { func upload( _ file: UploadFile, endpoint: any EndpointProtocol, mode: UploadMode ) async throws -> UploadReceipt}
В связке с EndpointPolicy.authorizedUpload() это даёт очень понятную модель: у upload свой timeout, свой retry, свой idempotency key и свой transport.
Безопасность
Здесь у меня немного правил, но они жёсткие.
|
Правило |
Почему я считаю его обязательным |
|---|---|
|
Bearer token только в header |
RFC 6750 рекомендует именно этот способ |
|
Токены в Keychain |
Это encrypted storage из платформы |
|
Никаких токенов в query |
URL с большой вероятностью логируется |
|
Redaction по умолчанию |
Любой developer log когда‑нибудь окажется в issue |
|
Idempotency для write‑flow |
Иначе повтор может породить дубль |
|
Raw body logging только в safe/debug‑safe режиме |
И то после очистки чувствительных данных |
И снова здесь полезны стандарты. RFC 6750 говорит, что bearer token должен быть защищён при хранении и транспортировке; тот же документ отдельно предупреждает о рисках передачи токена в query string. Для меня это не просто «совет от OAuth», а прямое правило проектирования сетевого слоя.
Если кратко, мой security baseline в сети выглядит так:
-
секреты — в Keychain,
-
Authorization— централизованно, -
logger — только redacted,
-
retry для write — только под policy,
-
background upload — отдельным transport‑ом,
-
snapshot пользователя — не в transport‑cache, а в app‑storage.
Почему не библиотека, компромиссы и итоги
Почему я не решаю всё одной библиотекой
Сразу скажу честно: я не считаю, что всем нужно писать свой сетевой слой с нуля. Если проект маленький, API простое, offline не нужен, upload тривиальный, а auth сводится к одному токену без refresh storm — готовая библиотека может быть прекрасным выбором.
Но когда у вас появляются:
-
явная endpoint‑level policy,
-
special raw‑response flow,
-
централизованный refresh,
-
request context с snapshots,
-
metrics,
-
snapshot‑first чтение,
-
background media transfer,
-
разделение REST и realtime,
-
строгий redaction,
— библиотека чаще закрывает transport, но не решает архитектурную границу.
Для себя я обычно сравниваю варианты так:
|
Вопрос |
Готовая библиотека |
Свой слой |
|---|---|---|
|
Быстрый старт |
Сильная сторона |
Медленнее |
|
Контроль над policy |
Часто ограничен |
Полный |
|
Интеграция с доменной архитектурой |
Требует адаптеров |
Делается под проект |
|
Специальные auth/polling‑flow |
Иногда неудобно |
Естественно |
|
Безопасное логирование и snapshots |
Нужно настраивать поверх |
Встраивается сразу |
|
Background media transport |
Часто отдельно |
Можно изначально учесть |
|
Стоимость поддержки |
Ниже на старте |
Выше на команде |
Мой практический вывод такой: если сеть для проекта — просто «сходить за JSON», берите библиотеку. Если сеть — это инфраструктура бизнеса, она почти всегда потребует собственной архитектуры, даже если transport собран на готовых деталях.
Компромиссы
У такого подхода есть цена. И лучше её признать в статье честно, чем продавать архитектуру как магию.
|
Решение |
Что даёт |
Что забирает |
|---|---|---|
|
Unified |
Явные правила и меньше магии |
Больше boilerplate |
|
Pipeline из interceptors |
Расширяемость и чистые обязанности |
Нужно следить за порядком interceptors |
|
|
Связанные логи, snapshots, retry‑cycle id |
Больше служебных сущностей |
|
Snapshot‑first |
Быстрый UI и offline |
Нужна чёткая invalidation‑стратегия |
|
Actor‑based token manager |
Защита от гонок refresh |
Нужно понимать concurrency‑модель |
|
Отдельный upload transport |
Устойчивость для медиа |
Появляется ещё один технический контур |
|
Safe logging |
Меньше риск утечки |
Иногда сложнее «просто быстро посмотреть payload» |
Главный честный вывод: сложность никуда не исчезает. Её можно либо размазать по экранам и repository, либо собрать в инфраструктуру с понятными точками ответственности. Я почти всегда выбираю второе.
Итоги
Когда я проектирую сетевой слой, я стараюсь всё время держать в голове одну простую мысль: главная задача сети не в том, чтобы сделать запрос, а в том, чтобы сделать поведение системы повторяемым.
Повторяемым для разработчика.
Повторяемым для логов.
Повторяемым для пользователя.
И повторяемым в тот момент, когда сеть плохая, backend нестабилен, refresh произошёл не вовремя, а upload идёт уже третью минуту.
Именно поэтому я:
-
описываю правила рядом с endpoint‑ом,
-
держу transport ниже data layer,
-
выношу auth/retry/logging/metrics в pipeline,
-
храню токены в Keychain,
-
делаю request context с snapshots,
-
предпочитаю snapshot‑first для UI,
-
и не даю feature‑коду договариваться с сетью напрямую.
Если свести всё к одной фразе, то она будет такой:
Хороший сетевой слой должен быть скучным.
Не «магическим».
Не «умным».
Не «удобным на один экран».
А скучным, явным, наблюдаемым и безопасным.
И в продакшене это, как правило, самый лучший комплимент для инфраструктуры.
ссылка на оригинал статьи https://habr.com/ru/articles/1044818/