Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Swift Combine HTTP Client with delayed retry
import Combine
import Foundation
struct HTTPClient {
let session: URLSession
let defaultRetryInterval: TimeInterval
let retryCount: Int
init(session: URLSession = .shared, retryCount: Int = 1, defaultRetryInterval: TimeInterval = 2) {
self.session = session
self.retryCount = retryCount
self.defaultRetryInterval = defaultRetryInterval
}
func perform<T: Decodable>(_ request: URLRequest, decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<T, Error> {
// Retry after a delay original idea:
// https://www.donnywals.com/retrying-a-network-request-with-a-delay-in-combine/
publisher(for: request)
.failOrRetry(retryCount)
.tryMap { result -> T in
try decoder.decode(T.self, from: result.data)
}
.eraseToAnyPublisher()
}
private func publisher(for request: URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), NetworkError> {
session
.dataTaskPublisher(for: request)
.mapError { NetworkError.urlError($0) }
.map { response -> AnyPublisher<(data: Data, response: URLResponse), NetworkError> in
guard let httpResponse = response.response as? HTTPURLResponse else {
return Fail(error: NetworkError.invalidResponse)
.eraseToAnyPublisher()
}
if httpResponse.statusCode >= 400 {
let error = NetworkError.httpError(httpResponse)
var delay: TimeInterval = 0
if error.canRetryHTTPError {
delay = error.retryAfter ?? defaultRetryInterval
}
return Fail(error: error)
.delay(for: .seconds(delay), scheduler: DispatchQueue.global())
.eraseToAnyPublisher()
}
return Just(response)
.setFailureType(to: NetworkError.self)
.eraseToAnyPublisher()
}
.switchToLatest()
.eraseToAnyPublisher()
}
}
private extension Publisher {
func failOrRetry<T, E>(
_ retries: Int
) -> Publishers.TryCatch<Self, AnyPublisher<T, E>> where T == Self.Output, E == Self.Failure {
tryCatch { error -> AnyPublisher<T, E> in
if let error = error as? NetworkError, error.canRetry {
return Publishers.Retry(upstream: self, retries: retries).eraseToAnyPublisher()
}
else {
throw error
}
}
}
}
import Foundation
enum NetworkError: Error {
/// An `URLSession` error.
case urlError(URLError)
/// `URLResponse` is not `HTTPURLResponse` or empty.
case invalidResponse
/// Status code is `≥ 400`.
case httpError(HTTPURLResponse)
}
private let retryAfterHeaderKey = "Retry-After"
extension NetworkError {
var canRetry: Bool { canRetryURLError || canRetryHTTPError }
var canRetryURLError: Bool {
if case let .urlError(urlError) = self {
switch urlError.code {
case .timedOut,
.cannotFindHost,
.cannotConnectToHost,
.networkConnectionLost,
.dnsLookupFailed,
.httpTooManyRedirects,
.resourceUnavailable,
.notConnectedToInternet,
.secureConnectionFailed,
.cannotLoadFromNetwork:
return true
default:
break
}
}
return false
}
var canRetryHTTPError: Bool {
if case let .httpError(response) = self {
let code = response.statusCode
if /* Too Many Requests */ code == 429 ||
/* Service Unavailable */ code == 503 ||
/* Request Timeout */ code == 408 ||
/* Gateway Timeout */ code == 504 {
return true
}
if response.allHeaderFields[retryAfterHeaderKey] != nil {
return true
}
}
return false
}
var retryAfter: TimeInterval? {
if case let .httpError(response) = self, let retryAfter = response.allHeaderFields[retryAfterHeaderKey] {
if let retryAfterSeconds = (retryAfter as? NSNumber)?.doubleValue {
return retryAfterSeconds
}
if let retryAfterString = retryAfter as? String {
if let retryAfterSeconds = Double(retryAfterString), retryAfterSeconds > 0 {
return retryAfterSeconds
}
let date = NetworkError.httpDateFormatter.date(from: retryAfterString)
let currentTime = CFAbsoluteTimeGetCurrent()
if let retryAbsoluteTime = date?.timeIntervalSinceReferenceDate, currentTime < retryAbsoluteTime {
return retryAbsoluteTime - currentTime
}
}
}
return nil
}
private static var httpDateFormatter: DateFormatter = {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#Examples
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
return dateFormatter
}()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment