swiftcompletionhandlerswift-concurrency

Swift concurrency async/await with do-something-only-on-success and retry


I'm converting our app's networking to use async/await, and I'm having a little trouble coming up with a satisfactory pattern to replace the completion handler that we were using previously. Here is some pseudo-code of what we do:

func loadData() {
    fetchData {
        showTheSearchField()
    }
}

func fetchData(completion: () -> ()) {
    doTheActualNetworking()
    if networkingSucceeded {
        completion()
    } else {
        putUpAnAlertWithATryAgainButton(buttonAction: {
            fetchData(completion: completion)
        }
    }
}

You see the point here. First we try to fetch the data, which we do by performing actual networking. Then:

My problem is that when I convert to async await, I can't find a way to eliminate the blasted the completion handler. For instance, in my example the completion handler says to show the search field. I can't just wrap the whole thing up as e.g. a throwing async method:

func loadData() {
    Task {
        try await fetchData()
        showTheSearchField()
    }
}

func fetchData() async throws {
    do {
        try await doTheActualNetworking()
    } catch {
        putUpAnAlertWithATryAgainButton(buttonAction: {
            Task {
                try await fetchData()
            }
        }
        throw error
    }
}

You see what's wrong with that? If we succeed on doTheActualNetworking the first time, fine, we fall back into the task in loadData and we proceed to show the search field. But if we fail and put up the alert and the user taps the try again button and we succeed this time, we won't show the search field, because we failed and threw and loadData came to an end.

So it seems to me that I am forced, because of the cyclic nature of the try again button, to maintain the completion / success handler, passing it into fetchData so that the try again button can recurse with it, just as I was doing before.

Well, that works fine, of course, but it feels to me like an anti-pattern. I'm adopting async/await exactly because I'd like never to see another completion handler as long as I live. Am I just stuck with the completion handler, or is there a way around this that I'm not seeing?


Solution

  • One approach would be to follow your recursive pattern:

    func loadData() async throws {
        try await fetchData() 
        showTheSearchField()
    }
    
    func fetchData() async throws {
        do {
            try await doTheActualNetworking()                // assume throws error if no network 
        } catch NetworkingError.noNetwork {
            try await putUpAnAlertWithATryAgainButton()      // assume returns if user tapped “try again”, but throws some “user cancelled error”  (or perhaps even `CancellationError`) if user taps on “cancel”
            try await fetchData()
        } catch {
            throw error                                      // for whatever errors you do not want a “retry” option, if any
        }
    }
    

    Or, obviously, you could also do it non-recursively (perhaps adding some “max retry” logic, too; that’s up to you):

    func fetchData() async throws {
        while true {
            do {
                try await doTheActualNetworking()            // assume throws error if no network
                return                                       // if no error thrown, just return
            } catch NetworkingError.noNetwork {
                try await putUpAnAlertWithATryAgainButton()  // assume returns if user tapped “try again”, but throws some “user cancelled error” (or perhaps even `CancellationError`) if user taps on “cancel”
            } catch {
                throw error                                  // for whatever errors you do not want a “retry” option, if any
            }
        }
    }
    

    FWIW, to wrap the alert in a continuation in UIKit, for example, it might look like:

    func putUpAnAlertWithATryAgainButton() async throws { 
        try await withCheckedThrowingContinuation { continuation in
            let alert = UIAlertController(title: nil, message: "There was a network error.", preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: "Try again", style: .default) { _ in
                continuation.resume()
            })
            alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
                continuation.resume(throwing: CancellationError())
            })
            present(alert, animated: true)
        }
    }
    

    Or, just wrap your current completion-handler-based function in a continuation:

    func putUpAnAlertWithATryAgainButton() async {
        await withCheckedContinuation { continuation in
            putUpAnAlertWithATryAgainButton {
                continuation.resume()
            }
        }
    }
    

    Personally, I would be inclined to give the “retry” UI a “cancel” option (so the user isn’t stuck in an endless loop if they happen to be in a no-network scenario) and then give the completion-handler a parameter indicating the result of which button they tapped. And in that case, we would then use a throwing continuation, like the UIAlertController example, but it comes down to whether the app is really completely dependent upon the presence of a functioning network connection or not. Obviously, if there is no “cancel” option, it would be a non-throwing continuation, and you would eliminate the try in the calls, but the idea is the same.

    There are lots of variations on the theme, but hopefully it illustrates the idea.