Adding async/await Functionality to URLSessionDataTask for Improved Request Cancellation

It is my first time opening a discussion and a PR.
So please feel free to give any advice hehe.

Motivation

Switching to async/await functions from URLSession revealed a critical issue - the loss of access to URLSessionDataTask. This becomes problematic, especially when the ability to cancel a request is essential, such as when working with Feed screens and similar features.

Solution

To address this issue, one proposed solution is to introduce a new function that allows performing URLSessionDataTask operations using async/await funcitons. This function would return a tuple containing the resulting data and response.

extension URLSession {
   func perform(dataTask: URLSessionDataTask) async -> (Data, Response)
}

Alternatives Considered

Another alternative considered is to enhance URLSessionDataTask itself by adding an async/await function akin to the existing resume() function. This would provide a more streamlined approach to achieve the same goal.

extension URLSessionDataTask {
   func resume() async -> (Data, Response)
}

This topic serves as a platform to discuss the merits, drawbacks, and potential implementation details of these proposed solutions and any other alternatives that may arise during the discussion. Your insights and contributions are welcome!

2 Likes

:+1: Ditto for other tasks like upload / download, etc.

Note that func resume() is already taken in the parent URLSessionTask and while the following is possible:

class A { func foo() {} }
class B: A { func foo() async {} }

as it is not technically an override, I don't think that'd be wise; and you'd probably better off with another unused name e.g. resumeTask().

If the task awaiting the request is cancelled, the request should also be cancelled. So wrap it in a detached task if you need a handle to cancel the operation later.

let t = Task.detached {
  print("Starting request")
  let (data, response) = try await URLSession.shared.data(from: URL(string: "http://postman-echo.com/bytes/10/mb?type=json")!)
  print("Request finished - \((response as! HTTPURLResponse).statusCode) (\(data.count) bytes)")
}

usleep(1000)
print("Cancelling task")
t.cancel()

print(await t.result)

Prints:

Starting request
Cancelling task
failure(Error Domain=NSURLErrorDomain Code=-999 "cancelled" UserInfo={NSErrorFailingURLStringKey=http://postman-echo.com/bytes/10/mb?type=json, NSErrorFailingURLKey=http://postman-echo.com/bytes/10/mb?type=json, _NSURLErrorRelatedURLSessionTaskErrorKey=(
    "LocalDataTask <EA29A550-84C3-4F53-9D45-E5E1924AC9C7>.<1>"
), _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataTask <EA29A550-84C3-4F53-9D45-E5E1924AC9C7>.<1>, NSLocalizedDescription=cancelled})

This works if the request is part of a larger operation (e.g. download some data, parse it from JSON, etc); the call to URLSession.shared.data will throw if the task gets cancelled, so things later in the task will not be invoked. In the example above, the line "Request finished" was not printed because of the cancellation.

In other words, you can just think about grouping your work in to tasks, and task cancellation, rather than having to cancel individual requests.

That doesn't mean that it wouldn't be useful to have async support in URLSession task objects, but since you mentioned cancellation as a specific motivation - this is how you do it.

3 Likes

No need to detach. Just an unstructured task (Task{}) is fine if you’re trying to avoid the cancellation propagation

2 Likes

As for Task.detached {} vs Task {}, both of them create unstructured tasks.

The difference is that Task {} inherits a bunch of context that you might not need. IMO, I think it often does more harm than good -- for instance, tasks launched from UI actions (including SwiftUI's .task modifier) have maximum priority, which then gets inherited by everything, obviously reducing the effectiveness of the priority system. My background refresh task or notification observers don't really need maximum scheduling priority (especially if they need to jump back on the main thread for anything).

So instead I tend to default to detached tasks, and make the use of the bare Task {} initialiser a deliberate choice for semantically related tasks. It depends on what the task is doing; they both have their uses.

1 Like

Apple added a URLSessionTaskDelegate method, urlSession(_:didCreateTask:), in 2022 to address the task unavailable issue.

If swift-foundation ever gets to URLSession I have a lot of thoughts but that's likely years away.

2 Likes

Interestingly, that delegate method is called synchronously:

/* Notification that a task has been created.  This method is the first message
 * a task sends, providing a place to configure the task before it is resumed.
 *
 * This delegate callback is *NOT* dispatched to the delegate queue.  It is
 * invoked synchronously before the task creation method returns.
 */
1 Like