Last active
January 20, 2024 00:25
-
-
Save podkovyrin/ed94d68ed561c84bbde99cdbe5cef02d to your computer and use it in GitHub Desktop.
Swift Combine HTTP Client with delayed retry
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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