CancellationError

Overview:

  • I have a function that makes an async request to download some content using the below mentioned code
  • When setup(from:) is cancelled it doesn't throw CancellationError
  • Instead it throws Error Domain=NSURLErrorDomain Code=-999 "cancelled"
  • I am not cancelling this task, this task is cancelled by SwiftUI when called from .task { }

Question:

  • How to properly detect when setup(from:) is cancelled? (SwiftUI is cancelling this task)
  • Is there a better / more reliable way to detect this error than checking for domain and error code (Error Domain=NSURLErrorDomain Code=-999 "cancelled)?
  • Or is there a better approach to solve this?

Note:

  • I was expecting CancellationError however it wasn't thrown
  • I would like to catch it inside setup(from:), let me know if there is a better way to handle this.

Code

func fetchNumbers(from url: URL) async throws -> [Int] {
    let session = URLSession(configuration: .ephemeral)
    
    let request = URLRequest(url: url)
    
    let (data, response) = try await session.data(for: request, delegate: nil)
    
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
        throw NSError(domain: "something", code: 111)
    }
    
    //do some processing
    return []
}

func setup(from url: URL) async {
    do {
        let numbers = try await fetchNumbers(from: url)
        print("numbers = \(numbers)")
    } catch {
        //How to catch it here?
        print("error")
    }
}

There is no guarantee that Task.CancellationError will be thrown when a task is cancelled -- the returned error depends on which subsystem notices the cancellation, which will throw whatever error is relevant to having its execution cancelled.
Task.CancellationError is only guaranteed to be thrown by Task.checkCancellation() if invoked, or can be used as a convenience type when you don't want/need your own error type to handle cancellation.

1 Like

Thanks a lot @layoutSubviews that was very clear.

I have handled it as follows:

let nsError = error as NSError

if nsError.domain == NSURLErrorDomain,
    nsError.code == NSURLErrorCancelled {
    //Handle cancellation
}

References:

A 100% reliable way to check if a task has been canceled is to check if Task.isCancelled is true.

You can also do this in your catch handler. Keep in mind that this still won't tell you whether the called function threw an error because it was canceled or for some other failure that occurred at the same time, but that distinction is probably not important in most cases.

If you know the concrete error an API throws on cancellation (as in your case), it's a good idea to check for it specifically.

For general purpose code where you don't know the specific error, I'd check for CancellationError and also for Task.isCancelled to cover all the APIs that don't throw a CancellationError.

2 Likes

Thanks a lot @ole
In my case when I try catch CancellationError was not thrown from the URLSession.data(for:delegate:), it threw Error Domain=NSURLErrorDomain Code=-999 "cancelled.

Based on your suggestion, now I am doing the following:

func setup(from url: URL) async {
    do {
        let numbers = try await fetchNumbers(from: url)
        print("numbers = \(numbers)")
    } catch (let cancellationError as CancellationError) {
        print("cancellation error: \(cancellationError)") //Not called
    } catch {
        //Error Domain=NSURLErrorDomain Code=-999 "cancelled" is thrown
        if Task.isCancelled {
            print("task was cancelled") //Gets called, so I can handle here
        } else {
            print("other error")
        }
    }
}

I guess as @layoutSubviews had stated CancellationError is only thrown when Task.checkCancellation() is executed.

Not only in that case; functions can also throw CancellationError() manually.

But yes, the lesson to take away from this is that checking for CancellationError is not a reliable way to see if a task has been canceled.

2 Likes

Thanks a lot @ole and @layoutSubviews

I have a better understanding now, I thought I understood until I applied it in a project :smile:

I feel more comfortable using Task.isCancelled inside the generic catch clause as I might not always be aware for the cancellation error thrown by the framework I am invoking.

If someone wants to catch URLError.cancelled, you can just catch them. See also: NSError bridging mechanism.

func setup(from url: URL) async {
    do {
        let numbers = try await fetchNumbers(from: url)
        print("numbers = \(numbers)")
    } catch URLError.cancelled {
        /* request has been cancelled */
    }
}
1 Like

Thanks a lot @yujingaya

You are right URLError.cancelled is the one being thrown in this case, thanks!!

1 Like