Search code examples
swiftasynchronousasync-awaitthrow

how to properly throw and catch an error from swift async function


I have a function that executes an async task. Sometimes that task fails and throws an error. I'm having trouble catching that error from the calling function. The playground below captures the essence of the trouble I'm having.

import UIKit

Task {
    var newNum: Double = 99.9
    do {
        newNum = try await getMyNumber()
        print("newNum within do: \(newNum)")
    } catch MyErrors.BeingStupid { //never gets caught
        print("caught being stupid")
    } catch MyErrors.JustBecause { //does get caught if throw is uncommented
        print("caught just because")
    }
    print("newNum outside of do \(newNum)")
}
print("done with main")

func getMyNumber() async throws -> Double {
    let retNum:Double = 0
    
    Task{
        sleep(5)
        let myNum: Double = Double.random(in: (0...10))
        if myNum > 9 {
            print("greater than 9")
        } else {
            print("less than 9 -- should throw")
            throw MyErrors.BeingStupid // error doesn't get thrown? HOW DO I CATCH THIS?
        }
    }
//    throw MyErrors.JustBecause  // this *does* get caught if uncommented
    return retNum //function always returns
}

enum MyErrors: Error {
    case BeingStupid, JustBecause
}

How do I catch the error being thrown at the line commented "HOW DO I CATCH THIS" back in the calling function?


Solution

  • Task is for unstructured concurrency. If the intent is to simulate an asynchronous task, I would advise remaining within structured concurrency. So, use Task.sleep(nanoseconds:) instead of sleep() and eliminate the Task within getMyNumber:

    func getMyNumber() async throws -> Double {
        try await Task.sleep(for: .seconds(5))      // better simulation of some asynchronous process
    
        let myNum = Double.random(in: 0...10)
        guard myNum > 9 else {
            print("less than or equal to 9 -- should throw")
            throw MyErrors.beingStupid
        }
    
        print("greater than 9")
        return myNum
    }
    

    If you stay within structured concurrency, errors that are thrown are easily caught.

    For more information about the difference between structured and unstructured concurrency, see The Swift Programming Guide: Concurrency or WWDC 2021 video Explore structured concurrency in Swift


    The above illustrates the standard structured concurrency pattern. If you really must use unstructured concurrency, you could try await the value returned by the Task, thereby re-throwing any errors thrown by the Task, e.g.:

    func getMyNumber() async throws -> Double {
        let task = Task.detached {
            let start = ContinuousClock().now
            while start.duration(to: .now) < .seconds(5) {
                Thread.sleep(forTimeInterval: 0.2)    // really bad idea ... never `sleep` (except `Task.sleep`, which is non-blocking) ... especially never use legacy `sleep` API on the main actor, which is why I used `Task.detached`
                try Task.checkCancellation()          // but periodically check to see if task was canceled 
                await Task.yield()                    // and if doing something slow and synchronous, periodically yield to Swift concurrency
            }
            
            let myNum = Double.random(in: 0...10)
            guard myNum > 9 else {
                print("less than or equal to 9 -- should throw")
                throw MyErrors.beingStupid
            }
    
            print("greater than 9")
            return myNum
        }
    
        return try await withTaskCancellationHandler { 
            try await task.value
        } onCancel: { 
            task.cancel()
        }
    }
    

    Note, because we’re running something slow and synchronous, we want it to run on a background thread, and therefore use Task.detached. But, when using unstructured concurrency we would want to properly handle cancelation, namely:

    • periodically check for cancelation;
    • wrap the Task in an withTaskCancellationHandler because we have to manually handle cancelation in using unstructured concurrency; and
    • periodically yield to Swift concurrency system.

    I only include this unstructured concurrency example for the sake of completeness. If you try await the value returned by the Task, errors will be properly propagated.

    All of that having been said, it is best to remain within structured concurrency, if you can. That way, you enjoy not only a more concise implementation, but also automatic propagation of cancelation, etc.