Использование прокси в 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. Вместо этого есть два рабочих подхода:
- Заголовок
Proxy-Authorization: Basic— добавляется в каждый запрос. - Реализация
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 failed | ATS блокирует или сертификат недоверенный | Обработайте 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.






