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?
CodePudding user response:
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.
CodePudding user response:
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()