Сетевой слой iOS‑приложения

от автора

Эта статья — не ревью чужого кода и не пересказ абстрактных паттернов. Это практическое описание того, как я подхожу к проектированию сетевого слоя, какие решения считаю удачными, какие — опасными, и почему.
Основа текста — реальный подход к построению сети в 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

Безопасность

Токены не живут в UserDefaults, не попадают в query и не утекут в логи

Наблюдаемость

У любого запроса есть request id, snapshots и метрики

Изоляция от UI

ViewModel не знает про headers, decoding и transport

Поддержка плохой сети

Есть стратегия для offline, retry, refresh и background upload

Масштабируемость

REST и realtime не смешиваются там, где это не нужно

Самое важное здесь — поведение должно быть описано явно. RFC 9110 различает безопасные и идемпотентные методы и отдельно предупреждает: автоматически повторять неидемпотентный запрос нельзя, если клиент не знает его семантику или не может доказать, что исходный запрос не был применён. Из этого для меня следует простой вывод: retry нельзя строить на догадке «ну это же GET» или «ну это же POST». Повторяемость должна лежать рядом с endpoint‑ом как часть контракта.

Чтобы показать, зачем всё это нужно, достаточно сравнить плохой и хороший старт проекта.

Подход

Почему выглядит удобно

Чем заканчивается

Один NetworkService.send()

Быстро начать

God Object, дубли, скрытые правила

Direct network из ViewModel

Меньше файлов

UI знает про URL, токены и транспорт

Retry по HTTP‑методу

Кажется «умным»

Ошибочные повторы, дубли записей, сложно дебажить

print(request/response)

Быстро видно проблему

Утечки токенов, телефонов, 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

Добавлять URLSession во ViewModel

Описать DTO

Добавлять Authorization руками

Написать RemoteDataSource

Делать 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 может быть описан в двух разных местах, а команда не всегда понимает, что считается источником правды.

Решение здесь не технически сложное, но дисциплинарно обязательное:

Проблема

Как выглядит

Как исправлять

Двойной источник правды

policy.auth = .none, но старое поле говорит обратное

Ввести жёсткий приоритет policy, старое — deprecated

Скрытый off‑by‑one в retry

.noRetry = maxAttempts: 0 читается как «вообще не выполнять»

Переименовать в maxRetries, а не maxAttempts

Магические 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 и собирает URLSessionTaskMetrics

ErrorMappingInterceptor

Маппит transport и HTTP в NetworkError, если raw mode выключен

Это намного безопаснее, чем один гигантский 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».

Я почти всегда разделяю три уровня хранения:

Данные

Где живут

Почему

accessToken, refreshToken

Keychain

Секреты, которые нельзя светить

currentUserSnapshot

локальное app‑хранилище

Быстрый старт и source of truth

transport cache

URLCache или отдельный disk 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}

Плохой подход здесь тоже очень типичный:

Плохое решение

Последствия

Токены в UserDefaults

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

Профиль только в 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. Эти вещи полезно не путать. citeturn6search2turn6search9

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 отвалился». citeturn5search3turn6search1turn6search18

Observability

Если нет логов и метрик, вы не управляете системой — вы гадаете.

Мой минимальный набор для сетевого слоя такой:

Что собираю

Зачем

request id

связать цепочку логов, retries и ошибок

duration

видеть медленные вызовы

status code

ловить всплески 4xx и 5xx

retry count

понимать нестабильность канала или backend

payload size

находить тяжёлые ответы

decoding failures

быстро видеть несовпадение контрактов

URLSessionTaskMetrics

понять 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 EndpointPolicy

Явные правила и меньше магии

Больше boilerplate

Pipeline из interceptors

Расширяемость и чистые обязанности

Нужно следить за порядком interceptors

RequestContext

Связанные логи, 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/