Использование прокси в Swift: руководство по URLSession для iOS и macOS

Практическое руководство для разработчиков: настройка HTTP и SOCKS5 прокси в URLSession, аутентификация, гео-таргетинг, async/await, ретраи и продакшен-советы для residential-прокси.

Using Proxies in Swift: A Code-First URLSession Guide for iOS & macOS

Использование прокси в Swift: зачем это нужно iOS-разработчику

Использование прокси в Swift — это не экзотика, а повседневная задача, когда вашему приложению нужно обращаться к API или веб-ресурсам, которые ограничивают доступ по гео-признаку или блокируют дата-центровые IP. URLSession поддерживает прокси «из коробки» через URLSessionConfiguration.connectionProxyDictionary, но документация Apple скупа на детали, а аутентификация и SOCKS5 добавляют нюансы.

В этом руководстве мы разберём, как настроить residential-прокси ProxyHat в URLSession на iOS и macOS, как работать с аутентификацией и гео-таргетингом, как использовать SOCKS5, и как построить надёжный конкурентный скрапинг на async/await. Все примеры используют gate.proxyhat.com:8080 для HTTP и gate.proxyhat.com:1080 для SOCKS5.

Почему residential-прокси необходимы для URLSession

Дата-центровые IP-адреса легко распознаются по ASN и часто блокируются. По данным документации Apple, URLSession прозрачно работает с системными прокси, но при ручной конфигурации через connectionProxyDictionary вы полностью контролируете маршрут трафика.

Residential-прокси используют IP реальных устройств в ISP, что делает их неотличимыми от обычных пользователей. Это критично для:

  • Сбор публичных данных — поисковые результаты, цены товаров, отзывы.
  • Доступ к регион-локированному контенту — стриминговые сервисы, локальные версии сайтов.
  • Тестирование — QA-команды проверяют, как приложение ведёт себя в разных странах.
  • SEO и SERP-трекинг — мониторинг позиций в разных регионах. Подробнее в нашем кейсе по SERP-трекингу.

Apple App Store Review Guidelines (раздел 5.3) требует, чтобы приложение собирало данные только из легитимных источников и уважало условия использования сайтов. Поэтому всегда предпочитайте официальные API, а прокси используйте как дополнение, а не замену.

Базовая настройка HTTP-прокси в URLSession

Ключевой словарь — connectionProxyDictionary — использует константы из CFNetwork. Для HTTP-прокси нужны kCFNetworkProxiesHTTPEnable, kCFNetworkProxiesHTTPProxy и kCFNetworkProxiesHTTPPort. Для HTTPS — аналогичные константы с суффиксом HTTPS.

import Foundation
import CFNetwork

func makeProxySession() -> URLSession {
    let config = URLSessionConfiguration.default
    config.connectionProxyDictionary = [
        kCFNetworkProxiesHTTPEnable: 1,
        kCFNetworkProxiesHTTPProxy: "gate.proxyhat.com",
        kCFNetworkProxiesHTTPPort: 8080,
        kCFNetworkProxiesHTTPSEnable: 1,
        kCFNetworkProxiesHTTPSProxy: "gate.proxyhat.com",
        kCFNetworkProxiesHTTPSPort: 8080
    ] as [String: Any]
    // Таймауты для продакшена
    config.timeoutIntervalForRequest = 30
    config.timeoutIntervalForResource = 120
    return URLSession(configuration: config)
}

// Быстрый тест
let session = makeProxySession()
let url = URL(string: "https://httpbin.org/ip")!
let task = session.dataTask(with: url) { data, response, error in
    if let error = error {
        print("Ошибка: \(error)")
        return
    }
    if let data = data, let body = String(data: data, encoding: .utf8) {
        print("IP через прокси: \(body)")
    }
}
task.resume()

Этот код направляет весь HTTP и HTTPS-трафик через gate.proxyhat.com:8080. Но без аутентификации прокси-сервер вернёт 407 Proxy Authentication Required. Переходим к следующему шагу.

Аутентификация и гео-таргетинг: Proxy-Authorization

Константы kCFProxyUsernameKey и kCFProxyPasswordKey ненадёжны в URLSession — они часто игнорируются на iOS. Вместо этого есть два рабочих подхода:

  1. Заголовок Proxy-Authorization: Basic — добавляется в каждый запрос.
  2. Реализация urlSession(_:didReceive:completionHandler:) — обработка 407 challenge.

ProxyHat кодирует страну, город и сессию прямо в username: user-country-US-city-newyork-session-abc123. Это позволяет управлять гео-таргетингом и липкими сессиями без отдельных API-вызовов.

Вариант 1: Proxy-Authorization в заголовке

import Foundation

struct ProxyConfig {
    let username: String  // напр. user-country-US-city-newyork-session-abc123
    let password: String
    
    var authHeader: String {
        let token = "\(username):\(password)"
        let data = token.data(using: .utf8)!
        return "Basic " + data.base64EncodedString()
    }
}

func makeRequest(to urlString: String, proxy: ProxyConfig) async throws -> Data {
    let config = URLSessionConfiguration.default
    config.connectionProxyDictionary = [
        kCFNetworkProxiesHTTPEnable: 1,
        kCFNetworkProxiesHTTPProxy: "gate.proxyhat.com",
        kCFNetworkProxiesHTTPPort: 8080,
        kCFNetworkProxiesHTTPSEnable: 1,
        kCFNetworkProxiesHTTPSProxy: "gate.proxyhat.com",
        kCFNetworkProxiesHTTPSPort: 8080
    ] as [String: Any]
    
    let session = URLSession(configuration: config)
    var request = URLRequest(url: URL(string: urlString)!)
    request.setValue(proxy.authHeader, forHTTPHeaderField: "Proxy-Authorization")
    
    let (data, response) = try await session.data(for: request)
    guard let http = response as? HTTPURLResponse,
          (200...299).contains(http.statusCode) else {
        throw URLError(.badServerResponse)
    }
    return data
}

// Использование с гео-таргетингом
let proxy = ProxyConfig(
    username: "user-country-DE-city-berlin-session-sess01",
    password: "yourpassword"
)
Task {
    let data = try await makeRequest(to: "https://httpbin.org/ip", proxy: proxy)
    print(String(data: data, encoding: .utf8) ?? "")
}

Вариант 2: URLSessionDelegate для 407 challenge

Более надёжный подход — обработка аутентификационного challenge через делегат. Это работает даже когда Proxy-Authorization не принимается некоторыми прокси-серверами.

import Foundation

class ProxyAuthDelegate: NSObject, URLSessionDelegate {
    let username: String
    let password: String
    
    init(username: String, password: String) {
        self.username = username
        self.password = password
    }
    
    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        switch challenge.protectionSpace.authenticationMethod {
        case NSURLAuthenticationMethodHTTPProxy:
            let credential = URLCredential(user: username, password: password, persistence: .forSession)
            completionHandler(.useCredential, credential)
        case NSURLAuthenticationMethodServerTrust:
            // Передаём TLS-валидацию системе
            if let trust = challenge.protectionSpace.serverTrust {
                completionHandler(.useCredential, URLCredential(trust: trust))
            } else {
                completionHandler(.cancelAuthenticationChallenge, nil)
            }
        default:
            completionHandler(.performDefaultHandling, nil)
        }
    }
}

func makeProxiedSession(username: String, password: String) -> URLSession {
    let config = URLSessionConfiguration.default
    config.connectionProxyDictionary = [
        kCFNetworkProxiesHTTPEnable: 1,
        kCFNetworkProxiesHTTPProxy: "gate.proxyhat.com",
        kCFNetworkProxiesHTTPPort: 8080,
        kCFNetworkProxiesHTTPSEnable: 1,
        kCFNetworkProxiesHTTPSProxy: "gate.proxyhat.com",
        kCFNetworkProxiesHTTPSPort: 8080
    ] as [String: Any]
    
    let delegate = ProxyAuthDelegate(username: username, password: password)
    return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}

// Гео-таргетинг: США, Нью-Йорк, липкая сессия
let session = makeProxiedSession(
    username: "user-country-US-city-newyork-session-abc123",
    password: "yourpassword"
)

Вариант с делегатом предпочтительнее в продакшене: он корректно обрабатывает TLS-челленджи и повторные 407-ответы при ротации прокси.

SOCKS5-прокси через kCFStreamPropertySOCKSProxy

SOCKS5 работает на транспортном уровне и поддерживает UDP, что может быть полезно для некоторых сценариев. В URLSession SOCKS5 настраивается через ключи kCFStreamPropertySOCKSProxyHost, kCFStreamPropertySOCKSProxyPort и kCFStreamPropertySOCKSVersion.

import Foundation
import CFNetwork

func makeSOCKS5Session(username: String, password: String) -> URLSession {
    let config = URLSessionConfiguration.default
    config.connectionProxyDictionary = [
        kCFNetworkProxiesSOCKSEnable: 1,
        kCFNetworkProxiesSOCKSProxy: "gate.proxyhat.com",
        kCFNetworkProxiesSOCKSPort: 1080,
        kCFStreamPropertySOCKSProxyHost: "gate.proxyhat.com",
        kCFStreamPropertySOCKSProxyPort: 1080,
        kCFStreamPropertySOCKSVersion: kCFStreamSocketSOCKSVersion5 as Any,
        kCFStreamPropertySOCKSUser: username,
        kCFStreamPropertySOCKSPassword: password
    ] as [String: Any]
    
    return URLSession(configuration: config)
}

let socksSession = makeSOCKS5Session(
    username: "user-country-GB-session-socks01",
    password: "yourpassword"
)
let url = URL(string: "https://httpbin.org/ip")!
let task = socksSession.dataTask(with: url) { data, _, error in
    if let data = data {
        print("SOCKS5 IP: \(String(data: data, encoding: .utf8) ?? "")")
    }
}
task.resume()
SOCKS5 на URLSession может не работать на всех версиях iOS. Тестируйте на целевой ОС. Для максимальной совместимости используйте HTTP-прокси на порту 8080.

Конкурентный веб-скрапинг на async/await с TaskGroup

Теперь соберём всё вместе. Представим, что нам нужно собрать данные о ценах с нескольких URL одновременно, используя разные гео-локации. Мы используем URLSession.shared.data(for:), Codable для декодирования и TaskGroup для управления конкурентностью.

import Foundation

struct PriceData: Codable {
    let product: String
    let price: Double
    let currency: String
}

struct ScrapeResult {
    let url: String
    let data: PriceData?
    let error: Error?
}

actor ProxyScraper {
    let proxyUsername: String
    let proxyPassword: String
    
    init(username: String, password: String) {
        self.proxyUsername = username
        self.proxyPassword = password
    }
    
    private func makeSession() -> URLSession {
        let config = URLSessionConfiguration.default
        config.connectionProxyDictionary = [
            kCFNetworkProxiesHTTPEnable: 1,
            kCFNetworkProxiesHTTPProxy: "gate.proxyhat.com",
            kCFNetworkProxiesHTTPPort: 8080,
            kCFNetworkProxiesHTTPSEnable: 1,
            kCFNetworkProxiesHTTPSProxy: "gate.proxyhat.com",
            kCFNetworkProxiesHTTPSPort: 8080
        ] as [String: Any]
        config.timeoutIntervalForRequest = 30
        config.httpMaximumConnectionsPerHost = 10
        return URLSession(configuration: config)
    }
    
    func scrape(urls: [String], country: String) async -> [ScrapeResult] {
        let session = makeSession()
        
        return await withTaskGroup(of: ScrapeResult.self) { group in
            for urlStr in urls {
                group.addTask {
                    var request = URLRequest(url: URL(string: urlStr)!)
                    let token = "\(self.proxyUsername):\(self.proxyPassword)"
                        .data(using: .utf8)!.base64EncodedString()
                    request.setValue("Basic " + token, forHTTPHeaderField: "Proxy-Authorization")
                    
                    do {
                        let (data, response) = try await session.data(for: request)
                        guard let http = response as? HTTPURLResponse,
                              (200...299).contains(http.statusCode) else {
                            return ScrapeResult(url: urlStr, data: nil, error: URLError(.badServerResponse))
                        }
                        let decoded = try JSONDecoder().decode(PriceData.self, from: data)
                        return ScrapeResult(url: urlStr, data: decoded, error: nil)
                    } catch {
                        return ScrapeResult(url: urlStr, data: nil, error: error)
                    }
                }
            }
            
            var results: [ScrapeResult] = []
            for await result in group {
                results.append(result)
            }
            return results
        }
    }
}

// Запуск
let scraper = ProxyScraper(
    username: "user-country-US-session-batch01",
    password: "yourpassword"
)
let urls = [
    "https://api.example.com/products/1",
    "https://api.example.com/products/2",
    "https://api.example.com/products/3",
    "https://api.example.com/products/4",
    "https://api.example.com/products/5"
]
Task {
    let results = await scraper.scrape(urls: urls, country: "US")
    for r in results {
        if let data = r.data {
            print("\(r.url): \(data.price) \(data.currency)")
        } else if let err = r.error {
            print("\(r.url) ошибка: \(err)")
        }
    }
}

Здесь httpMaximumConnectionsPerHost = 10 ограничивает параллелизм, а TaskGroup управляет жизненным циклом задач. Для больших нагрузок используйте наши рекомендации по веб-скрапингу.

Продакшен-советы: ретраи, ATS и TLS

Ретраи с экспоненциальной задержкой

Residential-прокси иногда возвращают 429 или 503. Ретраи с экспоненциальной задержкой — стандартный подход. Согласно RFC 7231, код 503 означает временную недоступность, и повторная попытка уместна.

import Foundation

enum RetryError: Error {
    case maxRetriesExceeded
    case nonRetryable(Int)
}

func fetchWithRetry(
    url: URL,
    session: URLSession,
    maxRetries: Int = 3,
    baseDelay: UInt64 = 1_000_000_000  // 1 секунда в наносекундах
) async throws -> Data {
    var attempt = 0
    while attempt < maxRetries {
        do {
            let (data, response) = try await session.data(from: url)
            if let http = response as? HTTPURLResponse {
                if (200...299).contains(http.statusCode) {
                    return data
                }
                // 429 и 503 — повторяемые
                if http.statusCode == 429 || http.statusCode == 503 {
                    let delay = baseDelay * UInt64(pow(2.0, Double(attempt)))
                    try await Task.sleep(nanoseconds: delay)
                    attempt += 1
                    continue
                }
                throw RetryError.nonRetryable(http.statusCode)
            }
            return data
        } catch is URLError {
            // Сетевые ошибки — повторяем
            let delay = baseDelay * UInt64(pow(2.0, Double(attempt)))
            try await Task.sleep(nanoseconds: delay)
            attempt += 1
            continue
        }
    }
    throw RetryError.maxRetriesExceeded
}

App Transport Security и TLS

ATS по умолчанию блокирует не-HTTPS соединения. Прокси-серверу отправляется CONNECT-запрос, а TLS устанавливается между клиентом и целевым сервером — ATS проверяет целевой сертификат. Если целевой сайт использует самоподписанный сертификат (например, в QA), вам понадобится обработка NSURLAuthenticationMethodServerTrust в делегате, как показано выше.

Не отключайте ATS глобально через NSAllowsArbitraryLoads — App Store может отклонить приложение. Вместо этого используйте NSExceptionDomains для конкретных доменов.

On-device privacy

Не храните учётные данные прокси в открытом виде. Используйте Keychain для хранения пароля и URLCredential(persistence: .forSession) для временного хранения в памяти. Согласно документации Apple Keychain Services, это рекомендуемый способ хранения секретов на iOS.

Типичные ошибки и их решения

ПроблемаПричинаРешение
407 Proxy Authentication RequiredПрокси не получил учётные данныеДобавьте Proxy-Authorization или реализуйте делегат
Таймаут через 30 секундResidential-прокси медленнее дата-центровыхУвеличьте timeoutIntervalForRequest до 45–60 секунд
SSLHandshake failedATS блокирует или сертификат недоверенныйОбработайте NSURLAuthenticationMethodServerTrust в делегате
429 Too Many RequestsСлишком высокая частота запросовРетраи с экспоненциальной задержкой, ротация сессий
kCFProxyUsernameKey игнорируетсяИзвестная проблема URLSession на iOSИспользуйте Proxy-Authorization или делегат

Настройка ProxyHat в Swift

ProxyHat предоставляет residential, mobile и datacenter прокси через единый шлюз gate.proxyhat.com. HTTP-порт — 8080, SOCKS5 — 1080. Гео-таргетинг и управление сессиями задаются прямо в username.

Параметры подключения:

  • HTTP: http://USERNAME:PASSWORD@gate.proxyhat.com:8080
  • SOCKS5: socks5://USERNAME:PASSWORD@gate.proxyhat.com:1080
  • Страна: user-country-US
  • Город: user-country-DE-city-berlin
  • Сессия: user-session-abc123

ProxyHat также предлагает SDK для Python и Node.js, которые используют тот же шлюз. Если ваш бэкенд на Python, логика идентична — просто другой HTTP-клиент. Подробнее см. в официальной документации ProxyHat. Цены и тарифные планы — на странице /pricing, список доступных локаций — на /locations.

Этика и правовые аспекты

Использование прокси для сбора публичных данных легально, если вы:

  • Уважаете robots.txt и условия использования сайта.
  • Не обходите технические средства защиты (CAPTCHA, paywalls).
  • Не собираете персональные данные без согласия (GDPR в ЕС, CCPA в Калифорнии).
  • Не нарушаете Computer Fraud and Abuse Act (CFAA) в США — доступ к защищённым системам без авторизации незаконен.

Apple App Store Review Guidelines (раздел 5.3) требует, чтобы приложение не собирало данные из недостоверных источников и не нарушало условия использования сторонних сервисов. Всегда предпочитайте официальные API, когда они доступны.

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

Ключевые выводы

  • Используйте connectionProxyDictionary с kCFNetworkProxiesHTTP* и kCFNetworkProxiesHTTPS* ключами для настройки HTTP-прокси в URLSession.
  • kCFProxyUsernameKey ненадёжен — используйте заголовок Proxy-Authorization: Basic или делегат urlSession(_:didReceive:).
  • Гео-таргетинг и сессии кодируются в username: user-country-US-city-newyork-session-abc123.
  • SOCKS5 работает через kCFStreamPropertySOCKSProxy* ключи на порту 1080, но совместимость зависит от версии iOS.
  • Ретраи с экспоненциальной задержкой и TaskGroup — основа надёжного конкурентного скрапинга.
  • Не отключайте ATS глобально; храните учётные данные в Keychain.
  • Всегда предпочитайте официальные API и уважайте robots.txt, GDPR и CFAA.

Готовы начать?

Доступ к более чем 50 млн резидентных IP в 148+ странах с AI-фильтрацией.

Смотреть ценыРезидентные прокси
← Вернуться в Блог