Swift - Queueing Combine Requests - ios

I am working on a Combine request which I want to either execute or queue to execute after a certain event. Below is the scenario -
New request is generated.
Check if the app has an access token
If yes, execute the request
If no, fetch token, then execute the request
Below is my API where every request will be triggered -
public func fetchData<T: Codable>(to request: URLRequest) -> AnyPublisher<Result<T>, Error> {
if hasToken {
return self.urlSession.dataTaskPublisher(for: request)
.tryMap(self.parseJson)
.receive(on: RunLoop.main)
.subscribe(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
else {
// Store request somewhere
// Get token
// Execute stored request
}
}
I would really appreciate it if anyone can suggest how I can proceed with the else part of my code.

The approach you're starting with wouldn't work if there were multiple requests in quick succession happening. All these requests would see hasToken as being false, and they will all initiate a token request.
You could possibly create a var tokenRequested: Bool property and synchronize access to it.
The way I approached this - not sure if this is the best approach - was by creating a pipeline through which all request would be queued:
class Service {
private let requestToken = PassthroughSubject<Void, Error>()
private let tokenSubject = CurrentValueSubject<Token?, Error>(nil)
var c: Set<AnyCancellable> = []
init() {
requestToken.zip(tokenSubject)
.flatMap { (_, token) -> AnyPublisher<Token, Error> in
if let token = token {
return Just(token).setFailureType(to: Error.self)
.eraseToAnyPublisher()
} else {
return self.fetchToken()
.eraseToAnyPublisher()
}
}
.map { $0 as Token? }
.subscribe(tokenSubject)
.store(in: &c)
}
private func fetchToken() -> AnyPublisher<Token, Error> {
// async code to fetch the token
}
}
Any request via requestToken syncs with a value from tokenSubject, so the first one goes together with the initial nil, but subsequent ones wait until publishes the next value, which happens when the previous one completes.
Then, to make a request, you'd first get the token getToken(), then make the request.
extension Service {
private func getToken() -> AnyPublisher<Token, Error> {
// request token, which starts queues it in the pipeline
requestToken.send(())
// wait until next token is available
return tokenSubject
.compactMap { $0 } // non-nil token
.first() // only one
.eraseToAnyPublisher()
}
func fetchData<T: Codable>(request: URLRequest) -> AnyPublisher<T, Error> {
getToken()
.flatMap { token in
// make request
}
.eraseToAnyPublisher()
}
}

Related

Swift Combine Cancel Publishers Without AnyCancellable

I have my networking library based on Combine. Anywhere in my app I can make a request and the networking library returns a publisher, it doesn't have access to the AnyCancellable that is created that actually triggers the pipeline. What I need is the ability to cancel all network requests when the use logs out. Is there a way to cancel Combine pipelines from the publisher not the AnyCancellable.
Here is an example:
var subscribers = [AnyCancellable]()
let url = URL(string:"https://www.apeth.com/pep/manny.jpg")!
let request: AnyPublisher<UIImage, URLError> = URLSession.shared.dataTaskPublisher(for: url)
.compactMap { UIImage(data:$0.data) }
.share()
.eraseToAnyPublisher()
request
.sink(receiveCompletion: { _ in
print("subscription2 completed")
}, receiveValue: { image in
print("subscription1 value: \(image.scale)")
})
.store(in: &subscribers)
// request.cancel()
I would like to call something like request.cancel() on the publisher so that the receiveValue is never triggered.
What I need is the ability to cancel all network requests when the use logs out.
I suggest you setup a publisher that emits when the user logs out. Then in your API system, you can use prefix(untilOutputFrom: logoutPublisher).
That way, all your network requests will cancel when the logoutPublisher emits.
The sink method returns a single AnyCancellable that you can store in
a dedicated property for the request instead of storing the cancellable in the array.
When you need to cancel the subscription, just deinitialize the cancellable, because, according to the documentation "An AnyCancellable instance automatically calls cancel() when deinitialized."
private var requestCancellable: AnyCancellable?
func setUpSubscription() {
requestCancellable = request
.sink(receiveCompletion: { _ in
print("subscription2 completed")
}, receiveValue: { image in
print("subscription1 value: \(image.scale)")
})
}
func cancelSubscription() {
requestCancellable = nil
}

Cancelling an async/await Network Request

I have a networking layer that currently uses completion handlers to deliver a result on the operation is complete.
As I support a number of iOS versions, I instead extend the network layer within the app to provide support for Combine. I'd like to extend this to now also a support Async/Await but I am struggling to understand how I can achieve this in a way that allows me to cancel requests.
The basic implementation looks like;
protocol HTTPClientTask {
func cancel()
}
protocol HTTPClient {
typealias Result = Swift.Result<(data: Data, response: HTTPURLResponse), Error>
#discardableResult
func dispatch(_ request: URLRequest, completion: #escaping (Result) -> Void) -> HTTPClientTask
}
final class URLSessionHTTPClient: HTTPClient {
private let session: URLSession
init(session: URLSession) {
self.session = session
}
func dispatch(_ request: URLRequest, completion: #escaping (HTTPClient.Result) -> Void) -> HTTPClientTask {
let task = session.dataTask(with: request) { data, response, error in
completion(Result {
if let error = error {
throw error
} else if let data = data, let response = response as? HTTPURLResponse {
return (data, response)
} else {
throw UnexpectedValuesRepresentation()
}
})
}
task.resume()
return URLSessionTaskWrapper(wrapped: task)
}
}
private extension URLSessionHTTPClient {
struct UnexpectedValuesRepresentation: Error {}
struct URLSessionTaskWrapper: HTTPClientTask {
let wrapped: URLSessionTask
func cancel() {
wrapped.cancel()
}
}
}
It very simply provides an abstraction that allows me to inject a URLSession instance.
By returning HTTPClientTask I can call cancel from a client and end the request.
I extend this in a client app using Combine as follows;
extension HTTPClient {
typealias Publisher = AnyPublisher<(data: Data, response: HTTPURLResponse), Error>
func dispatchPublisher(for request: URLRequest) -> Publisher {
var task: HTTPClientTask?
return Deferred {
Future { completion in
task = self.dispatch(request, completion: completion)
}
}
.handleEvents(receiveCancel: { task?.cancel() })
.eraseToAnyPublisher()
}
}
As you can see I now have an interface that supports canceling tasks.
Using async/await however, I am unsure what this should look like, how I can provide a mechanism for canceling requests.
My current attempt is;
extension HTTPClient {
func dispatch(_ request: URLRequest) async -> HTTPClient.Result {
let task = Task { () -> (data: Data, response: HTTPURLResponse) in
return try await withCheckedThrowingContinuation { continuation in
self.dispatch(request) { result in
switch result {
case let .success(values): continuation.resume(returning: values)
case let .failure(error): continuation.resume(throwing: error)
}
}
}
}
do {
let output = try await task.value
return .success(output)
} catch {
return .failure(error)
}
}
}
However this simply provides the async implementation, I am unable to cancel this.
How should this be handled?
Swift’s new concurrency model handles cancellation perfectly well. While the WWDC 2021 videos focused on the checkCancellation and isCancelled patterns (e.g., the Explore structured concurrency in Swift video), in this case, one would use withTaskCancellationHandler to create a task that cancels the network request when the task, itself, is canceled. (Obviously, this is only a concern in iOS 13/14, as in iOS 15 one would just use the provided async methods, data(for:delegate) or data(from:delegate:), which also handle cancelation well.)
See SE-0300: Continuations for interfacing async tasks with synchronous code: Additional Examples for example. That download example is a bit outdated, so here is an updated rendition:
extension URLSession {
#available(iOS, deprecated: 15, message: "Use `data(from:delegate:)` instead")
#available(macOS, deprecated: 12, message: "Use `data(from:delegate:)` instead")
func data(with url: URL) async throws -> (URL, URLResponse) {
try await download(with: URLRequest(url: url))
}
#available(iOS, deprecated: 15, message: "Use `data(for:delegate:)` instead")
#available(macOS, deprecated: 12, message: "Use `data(for:delegate:)` instead")
func data(with request: URLRequest) async throws -> (Data, URLResponse) {
let sessionTask = SessionTask(session: self)
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
Task {
await sessionTask.data(for: request) { data, response, error in
guard let data, let response else {
continuation.resume(throwing: error ?? URLError(.badServerResponse))
return
}
continuation.resume(returning: (data, response))
}
}
}
} onCancel: {
Task { await sessionTask.cancel() }
}
}
}
private extension URLSession {
actor SessionTask {
var state: State = .ready
private let session: URLSession
init(session: URLSession) {
self.session = session
}
func cancel() {
if case .executing(let task) = state {
task.cancel()
}
state = .cancelled
}
}
}
// MARK: Data
extension URLSession.SessionTask {
func data(for request: URLRequest, completionHandler: #Sendable #escaping (Data?, URLResponse?, Error?) -> Void) {
if case .cancelled = state {
completionHandler(nil, nil, CancellationError())
return
}
let task = session.dataTask(with: request, completionHandler: completionHandler)
state = .executing(task)
task.resume()
}
}
extension URLSession.SessionTask {
enum State {
case ready
case executing(URLSessionTask)
case cancelled
}
}
A few minor observations on my code snippet:
I gave these names to avoid collision with the iOS 15 method names, but added deprecated messages to inform the developer to use the iOS 15 renditions once you abandon iOS 13/14 support.
I deviated from SE-0300’s example to follow the pattern of the data(from:delegate:) and data(for:delegate:) methods (returning a tuple with Data and a URLResponse).
The actor, not in the original example, is needed to synchronize the access to the URLSessionTask.
Note that according to SE-0304, that regarding withTaskCancellationHandler:
If the task has already been cancelled at the point withTaskCancellationHandler is called, the cancellation handler is invoked immediately, before the operation block is executed.
Because of this, the actor in the above uses a state variable to determine if the request has already been canceled, and just immediately resumes, throwing a CancellationError if it is already canceled.
But all of that is unrelated to the question at hand. In short, use withTaskCancellationHandler.
E.g. Here are five image requests that I started in a task group, as monitored by Charles:
And here are the same requests, but this time I canceled the whole task group (and the cancelations successfully stopped the associated network requests for me):
(Obviously the x-axis scale is different.)
If you need download renditions (to wrap downloadTask), you could do supplement the above with:
extension URLSession {
#available(iOS, deprecated: 15, message: "Use `download(from:delegate:)` instead")
#available(macOS, deprecated: 12, message: "Use `download(from:delegate:)` instead")
func download(with url: URL) async throws -> (URL, URLResponse) {
try await download(with: URLRequest(url: url))
}
#available(iOS, deprecated: 15, message: "Use `download(for:delegate:)` instead")
#available(macOS, deprecated: 12, message: "Use `download(for:delegate:)` instead")
func download(with request: URLRequest) async throws -> (URL, URLResponse) {
let sessionTask = SessionTask(session: self)
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
Task {
await sessionTask.download(for: request) { location, response, error in
guard let location, let response else {
continuation.resume(throwing: error ?? URLError(.badServerResponse))
return
}
// since continuation can happen later, let’s figure out where to store it ...
let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(request.url!.pathExtension)
// ... and move it to there
do {
try FileManager.default.moveItem(at: location, to: tempURL)
} catch {
continuation.resume(throwing: error)
return
}
continuation.resume(returning: (tempURL, response))
}
}
}
} onCancel: {
Task { await sessionTask.cancel() }
}
}
}
extension URLSession.SessionTask {
func download(for request: URLRequest, completionHandler: #Sendable #escaping (URL?, URLResponse?, Error?) -> Void) {
if case .cancelled = state {
completionHandler(nil, nil, CancellationError())
return
}
let task = session.downloadTask(with: request, completionHandler: completionHandler)
state = .executing(task)
task.resume()
}
}
You can't hybridize Combine with async/await. If you embrace async/await fully and call one of the async download methods...
https://developer.apple.com/documentation/foundation/urlsession/3767353-data
...then the task where you call that method will be cancellable in good order through the standard structured concurrency mechanism.
So if you want to support Swift 5.5 / iOS 15 async and yet support earlier versions too, you will need two completely independent implementations of this functionality.
async/await might not be the proper paradigm if you want cancellation. The reason is that the new structured concurrency support in Swift allows you to write code that looks single-threaded/synchronous, but it fact it's multi-threaded.
Take for example a naive synchronous code:
let data = tryData(contentsOf: fileURL)
If the file is huge, then it might take a lot of time for the operation to finish, and during this time the operation cannot be cancelled, and the caller thread is blocked.
Now, assuming Data exports an async version of the above initializer, you'd write the async version of the code similar to this:
let data = try await Data(contentsOf: fileURL)
For the developer, it's the same coding style, once the operation finishes, they'll either have a data variable to use, or they'll be receiving an error.
In both cases, there's no cancellation built in, as the operation is synchronous from the developer's perspective. The major difference is that the await-ed call doesn't block the caller thread, but on the other hand once the control flow returns it might well be that the code continues executing on a different thread.
Now, if you need support for cancellation, then you'll have to store somewhere some identifiable data that can be used to cancel the operation.
If you'll want to store those identifiers from the caller scope, then you'll need to split your operation in two: initialization, and execution.
Something along the lines of
extension HTTPClient {
// note that this is not async
func task(for request: URLRequest) -> HTTPClientTask {
// ...
}
}
class HTTPClientTask {
func dispatch() async -> HTTPClient.Result {
// ...
}
}
let task = httpClient.task(for: urlRequest)
self.theTask = task
let result = await task.dispatch()
// somewhere outside the await scope
self.theTask.cancel()

Combine Future block called multiple times when using Flatmap and multiple subscribers

I've been successfully using BrightFutures in my apps mainly for async network requests. I decided it was time to see if I could migrate to Combine. However what I find is that when I combine two Futures using flatMap with two subscribers my second Future code block is executed twice. Here's some example code which will run directly in a playground:
import Combine
import Foundation
extension Publisher {
func showActivityIndicatorWhileWaiting(message: String) -> AnyCancellable {
let cancellable = sink(receiveCompletion: { _ in Swift.print("Hide activity indicator") }, receiveValue: { (_) in })
Swift.print("Busy: \(message)")
return cancellable
}
}
enum ServerErrors: Error {
case authenticationFailed
case noConnection
case timeout
}
func authenticate(username: String, password: String) -> Future<Bool, ServerErrors> {
Future { promise in
print("Calling server to authenticate")
DispatchQueue.main.async {
promise(.success(true))
}
}
}
func downloadUserInfo(username: String) -> Future<String, ServerErrors> {
Future { promise in
print("Downloading user info")
DispatchQueue.main.async {
promise(.success("decoded user data"))
}
}
}
func authenticateAndDownloadUserInfo(username: String, password: String) -> some Publisher {
return authenticate(username: username, password: password).flatMap { (isAuthenticated) -> Future<String, ServerErrors> in
guard isAuthenticated else {
return Future {$0(.failure(.authenticationFailed)) }
}
return downloadUserInfo(username: username)
}
}
let future = authenticateAndDownloadUserInfo(username: "stack", password: "overflow")
let cancellable2 = future.showActivityIndicatorWhileWaiting(message: "Please wait downloading")
let cancellable1 = future.sink(receiveCompletion: { (completion) in
switch completion {
case .finished:
print("Completed without errors.")
case .failure(let error):
print("received error: '\(error)'")
}
}) { (output) in
print("received userInfo: '\(output)'")
}
The code simulates making two network calls and flatmaps them together as a unit which either succeeds or fails.
The resulting output is:
Calling server to authenticate
Busy: Please wait downloading
Downloading user info
Downloading user info <---- unexpected second network call
Hide activity indicator
received userInfo: 'decoded user data'
Completed without errors.
The problem is downloadUserInfo((username:) appears to be called twice. If I only have one subscriber then downloadUserInfo((username:) is only called once. I have an ugly solution that wraps the flatMap in another Future but feel I missing something simple. Any thoughts?
When you create the actual publisher with let future, append the .share operator, so that your two subscribers subscribe to a single split pipeline.
EDIT: As I've said in my comments, I'd make some other changes in your pipeline. Here's a suggested rewrite. Some of these changes are stylistic / cosmetic, as an illustration of how I write Combine code; you can take it or leave it. But other things are pretty much de rigueur. You need Deferred wrappers around your Futures to prevent premature networking (i.e. before the subscription happens). You need to store your pipeline or it will go out of existence before networking can start. I've also substituted a .handleEvents for your second subscriber, though if you use the above solution with .share you can still use a second subscriber if you really want to. This is a complete example; you can just copy and paste it right into a project.
class ViewController: UIViewController {
enum ServerError: Error {
case authenticationFailed
case noConnection
case timeout
}
var storage = Set<AnyCancellable>()
func authenticate(username: String, password: String) -> AnyPublisher<Bool, ServerError> {
Deferred {
Future { promise in
print("Calling server to authenticate")
DispatchQueue.main.async {
promise(.success(true))
}
}
}.eraseToAnyPublisher()
}
func downloadUserInfo(username: String) -> AnyPublisher<String, ServerError> {
Deferred {
Future { promise in
print("Downloading user info")
DispatchQueue.main.async {
promise(.success("decoded user data"))
}
}
}.eraseToAnyPublisher()
}
func authenticateAndDownloadUserInfo(username: String, password: String) -> AnyPublisher<String, ServerError> {
let authenticate = self.authenticate(username: username, password: password)
let pipeline = authenticate.flatMap { isAuthenticated -> AnyPublisher<String, ServerError> in
if isAuthenticated {
return self.downloadUserInfo(username: username)
} else {
return Fail<String, ServerError>(error: .authenticationFailed).eraseToAnyPublisher()
}
}
return pipeline.eraseToAnyPublisher()
}
override func viewDidLoad() {
super.viewDidLoad()
authenticateAndDownloadUserInfo(username: "stack", password: "overflow")
.handleEvents(
receiveSubscription: { _ in print("start the spinner!") },
receiveCompletion: { _ in print("stop the spinner!") }
).sink(receiveCompletion: {
switch $0 {
case .finished:
print("Completed without errors.")
case .failure(let error):
print("received error: '\(error)'")
}
}) {
print("received userInfo: '\($0)'")
}.store(in: &self.storage)
}
}
Output:
start the spinner!
Calling server to authenticate
Downloading user info
received userInfo: 'decoded user data'
stop the spinner!
Completed without errors.

How can I continue URLSession dataTaskPublisher or another Publisher after error?

I have an app that needs to check a status on a server:
every 30 seconds
whenever the app enters the foreground
I'm doing this by merging two publishers, then calling flatMap the merged publisher's output to trigger the API request.
I have a function that makes an API request and returns a publisher of the result, also including logic to check the response and throw an error depending on its contents.
It seems that once a StatusError.statusUnavailable error is thrown, the statusSubject stops getting updates. How can I change this behavior so the statusSubject continues getting updates after the error? I want the API requests to continue every 30 seconds and when the app is opened, even after there is an error.
I also have a few other points where I'm confused about my current code, indicated by comments, so I'd appreciate any help, explanation, or ideas in those areas too.
Here's my example code:
import Foundation
import SwiftUI
import Combine
struct StatusResponse: Codable {
var response: String?
var error: String?
}
enum StatusError: Error {
case statusUnavailable
}
class Requester {
let statusSubject = CurrentValueSubject<StatusResponse,Error>(StatusResponse(response: nil, error: nil))
private var cancellables: [AnyCancellable] = []
init() {
// Check for updated status every 30 seconds
let timer = Timer
.publish(every: 30,
tolerance: 10,
on: .main,
in: .common,
options: nil)
.autoconnect()
.map { _ in true } // how else should I do this to be able to get these two publisher outputs to match so I can merge them?
// also check status on server when the app comes to the foreground
let foreground = NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.map { _ in true }
// bring the two publishes together
let timerForegroundCombo = timer.merge(with: foreground)
timerForegroundCombo
// I don't understand why this next line is necessary, but the compiler gives an error if I don't have it
.setFailureType(to: Error.self)
.flatMap { _ in self.apiRequest() }
.subscribe(statusSubject)
.store(in: &cancellables)
}
private func apiRequest() -> AnyPublisher<StatusResponse, Error> {
let url = URL(string: "http://www.example.com/status-endpoint")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return URLSession.shared.dataTaskPublisher(for: request)
.mapError { $0 as Error }
.map { $0.data }
.decode(type: StatusResponse.self, decoder: JSONDecoder())
.tryMap({ status in
if let error = status.error,
error.contains("status unavailable") {
throw StatusError.statusUnavailable
} else {
return status
}
})
.eraseToAnyPublisher()
}
}
Publishing a failure always ends a subscription. Since you want to continue publishing after an error, you cannot publish your error as a failure. You must instead change your publisher's output type. The standard library provides Result, and that's what you should use.
func makeStatusPublisher() -> AnyPublisher<Result<StatusResponse, Error>, Never> {
let timer = Timer
.publish(every: 30, tolerance: 10, on: .main, in: .common)
.autoconnect()
.map { _ in true } // This is the correct way to merge with the notification publisher.
let notes = NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.map { _ in true }
return timer.merge(with: notes)
.flatMap({ _ in
statusResponsePublisher()
.map { Result.success($0) }
.catch { Just(Result.failure($0)) }
})
.eraseToAnyPublisher()
}
This publisher emits either .success(response) or .failure(error) periodically, and never completes with a failure.
However, you should ask yourself, what happens if the user switches apps repeatedly? Or what if the API request takes more that 30 seconds to complete? (Or both?) You'll get multiple requests running simultaneously, and the responses will be handled in the order they arrive, which might not be the order in which the requests were sent.
One way to fix this would be to use flatMap(maxPublisher: .max(1)) { ... }, which makes flatMap ignore timer and notification signals while it's got a request outstanding. But it would perhaps be even better for it to start a new request on each signal, and cancel the prior request. Change flatMap to map followed by switchToLatest for that behavior:
func makeStatusPublisher2() -> AnyPublisher<Result<StatusResponse, Error>, Never> {
let timer = Timer
.publish(every: 30, tolerance: 10, on: .main, in: .common)
.autoconnect()
.map { _ in true } // This is the correct way to merge with the notification publisher.
let notes = NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.map { _ in true }
return timer.merge(with: notes)
.map({ _ in
statusResponsePublisher()
.map { Result<StatusResponse, Error>.success($0) }
.catch { Just(Result<StatusResponse, Error>.failure($0)) }
})
.switchToLatest()
.eraseToAnyPublisher()
}
You can use retry() to get this kind of behaviour or catch it...more info here:
https://www.avanderlee.com/swift/combine-error-handling/

Ensuring method scope is not completed until URLSession data task is completed in Swift 4

I want my application to return to the original API call. Here is a sample code:
func getDataFromServer()
{
guard let apiUrl = URL(string: endPoint) else {
return
}
getAccessTokenValue( completionHandler:{ accesstoken in
//if access token is valid continue to get data
//.......
let task = self.apiSession.dataTask(with: apiRequest, completionHandler: {
(data, response, error) in
//Manage response/data/error
})
task.resume()
})
}
func getAccessTokenValue(completionHandlerResult : #escaping (_ accesstoken : String) -> ())
{
var hasAccessTokenExpired = true
// do some calculation
//check if access token has expired
if hasAccessTokenExpired
{
self.renewAccessToken(completionHandler: { accessTokenValue in
completionHandlerResult(accessTokenValue)})
}
else{
completionHandlerResult( accessToken) //return the current access token
}
}
func static func renewAccessToken (completionHandler : #escaping (_ accessTokenValue:String) -> ()) //
{
//another API call to refresh Access Token
//.......
let task = self.apiSession.dataTask(with: apiRequest, completionHandler: {
(data, response, error) in
//Manage response/data/error
//read access token from response
completionHandler(accessToken)
})
task.resume()
}
The problem I am facing is when my access token is no longer valid, the API for refresh token is fired but by the time the data task in renewAccessToken is completed, my application has already executed the end of getAccessTokenValue and getDataFromServer. So the API which should have been executed after getting the refreshed access token (in getDataFromServer's completion handler for getAccessTokenValue), is no longer fired.
I am not able to figure out where I have gone wrong.
Resolved the issue. It was a problem with completion handler not being called when the API failed. I added that and now it works perfectly.

Resources