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

112 Views Asked by At

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:

  • If we succeeded, and only if we succeeded, then do whatever the caller wants done in case of success (what I have called completion, but perhaps a better name would be onSuccess).

  • If we failed, put up an alert with a try again button, and the action of that button is to fetch the data again and behave just the same way again — if we succeed this time, and only if we succeed this time, then do whatever the caller wants done in case of success.

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?

2

There are 2 best solutions below

1
Rob On BEST ANSWER

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.

0
matt On

Thanks so much to @Sweeper and @Rob. Just to help out anyone who wants to see how this turned out:

This was a lot easier to implement than I thought it would be. The solution involved a new pattern of use for withCheckedContinuation for me, so this question-and-answer has taught me something important. I'm used to using withCheckedContinuation as basically the only thing in an async method (usually returning it implicitly), in converting old non-async/await code into async/await. But it turns out you can say e.g.

let response = await withCheckedContinuation... 

anywhere in an async method. That's the game-changing realization for me.

The only hard part was that in our app we already have these Alert objects that have to be configured with their cancel and tryAgain handlers at initialization time; I can't modify them later on. But I made an additional initializer that takes a Continuation as one of the parameters, so I can form those handlers right there in the initializer. And bingo, it's working perfectly. I was able to turn fetchData into a true async throws method, with no internal Task and no completion handler, and all the right things are happening.

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

@MainActor func fetchData() async throws {
    try {
        doTheActualNetworking()
    } catch {
        let buttonTapped = await withCheckedContinuation { continuation in
            putUpAnAlertWithATryAgainButton(continuation: continuation)
        }
        switch buttonTapped {
        case .cancel:
            throw error
        case .tryAgain:
            try await fetchData()
        }
    }
}

This is really easy to read, and it does exactly the right thing. If networking succeeds, we show the search field. If networking fails, we put up the alert. If the user taps cancel, that's a failure and we don't show the search field. If the user taps try again, we recurse, and so we attempt to networking again, and everything I just said (starting with the words "If networking succeeds") remains true.