HTTP клиент на Swift: делаем запросы правильно — продвинутый уровень

Онлайн-тренажер Python для начинающих

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

Начать курс

Введение: от базовых GET к промышленному HTTP-клиенту

Стандартный HTTP-запрос на Swift — это три строки кода с URLSession.shared.dataTask. Но в реальных проектах (загрузка файлов, авторизация с токенами, кеширование, ретраи) этого недостаточно. В этой статье мы разберем продвинутые техники создания HTTP-клиента на Swift для iOS и macOS: кастомные протоколы, управление сессиями, многопоточность через async/await и Combine, а также надежную обработку ошибок. Материал рассчитан на разработчиков, уже знакомых с основами URLSession.

Архитектура продвинутого HTTP-клиента

Промышленный клиент — это не просто функция запроса, а слой абстракции. Разделим его на три уровня:

  • Transport Layer — низкоуровневая работа с URLSession, управление конфигурацией.
  • Service Layer — сериализация/десериализация, обработка статус-кодов, ретраи.
  • Application Layer — бизнес-логика, кеширование, авторизация.

Начнем с кастомизации Transport Layer через URLProtocol.

1. Кастомный URLProtocol для перехвата и модификации запросов

URLProtocol — мощный механизм для перехвата всех сетевых запросов в приложении. С его помощью можно добавить логирование, подпись запросов или мок-данные для тестирования.

import Foundation

class LoggingProtocol: URLProtocol {
    override class func canInit(with request: URLRequest) -> Bool {
        // Перехватываем все HTTP/HTTPS запросы
        return request.url?.scheme == "http" || request.url?.scheme == "https"
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    override func startLoading() {
        print("📤 Запрос: \(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "")")
        print("📦 Заголовки: \(request.allHTTPHeaderFields ?? [:])")

        let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
            guard let self = self else { return }
            if let error = error {
                print("❌ Ошибка: \(error.localizedDescription)")
                self.client?.urlProtocol(self, didFailWithError: error)
            } else if let data = data, let response = response {
                print("✅ Ответ: \(response.expectedContentLength) байт")
                self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed)
                self.client?.urlProtocol(self, didLoad: data)
            }
            self.client?.urlProtocolDidFinishLoading(self)
        }
        task.resume()
    }

    override func stopLoading() {
        // Ничего не делаем, задача завершается сама
    }
}

// Регистрация протокола
URLProtocol.registerClass(LoggingProtocol.self)
let config = URLSessionConfiguration.default
config.protocolClasses = [LoggingProtocol.self]
let session = URLSession(configuration: config)

Теперь все запросы через эту сессию будут логироваться. Аналогично можно добавить автоматическое добавление Bearer-токена или обработку 401 статуса.

2. Управление сессиями: кастомная конфигурация и многопоточность

Используйте разные URLSessionConfiguration для разных целей: .default для обычных запросов, .ephemeral для приватных данных (без кеша и куков), .background для фоновой загрузки.

let backgroundConfig = URLSessionConfiguration.background(withIdentifier: "com.pythonlib.background")
backgroundConfig.isDiscretionary = true
backgroundConfig.sessionSendsLaunchEvents = true
let backgroundSession = URLSession(configuration: backgroundConfig, delegate: self, delegateQueue: nil)

Для многопоточности используйте async/await (Swift 5.5+):

func performRequest(url: URL) async throws -> Data {
    let (data, response) = try await URLSession.shared.data(from: url)
    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {
        throw NetworkError.invalidResponse
    }
    return data
}

// Использование
Task {
    do {
        let data = try await performRequest(url: url)
        // Обработка данных
    } catch {
        print("Ошибка: \(error)")
    }
}

Для Combine-подхода используйте dataTaskPublisher:

import Combine

var cancellables = Set<AnyCancellable>()

func fetchData(with url: URL) -> AnyPublisher<Data, Error> {
    return URLSession.shared.dataTaskPublisher(for: url)
        .tryMap { data, response in
            guard let httpResponse = response as? HTTPURLResponse,
                  (200...299).contains(httpResponse.statusCode) else {
                throw NetworkError.invalidResponse
            }
            return data
        }
        .eraseToAnyPublisher()
}

// Подписка
fetchData(with: url)
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { completion in
        // Обработка ошибок
    }, receiveValue: { data in
        // Обработка данных
    })
    .store(in: &cancellables)

3. Продвинутая обработка ошибок и ретраи

Сеть нестабильна. Добавим механизм повторных попыток с экспоненциальной задержкой (exponential backoff) и джиттером.

enum NetworkError: Error {
    case invalidURL
    case invalidResponse
    case serverError(Int)
    case decodingError
    case noConnection
}

func performRequestWithRetry(url: URL, retries: Int = 3) async throws -> Data {
    var lastError: Error?
    for attempt in 0..<retries {
        do {
            let data = try await performRequest(url: url)
            return data
        } catch {
            lastError = error
            if attempt < retries - 1 {
                // Экспоненциальная задержка: 2^attempt секунд + джиттер
                let delay = Double(1 << attempt) + Double.random(in: 0...0.5)
                print("⚠️ Попытка \(attempt + 1) не удалась, повтор через \(delay) сек")
                try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
            }
        }
    }
    throw lastError ?? NetworkError.noConnection
}

Для Combine-версии используйте retry оператор:

func fetchWithRetry(url: URL) -> AnyPublisher<Data, Error> {
    return URLSession.shared.dataTaskPublisher(for: url)
        .tryMap { data, response in
            // проверка статуса
            return data
        }
        .retry(3)
        .eraseToAnyPublisher()
}

Не забывайте про кеширование: настройте URLCache для снижения нагрузки:

let cache = URLCache(memoryCapacity: 50_000                

Блоги

Рекомендаций по книгам