Articles, podcasts and news about Swift development, by John Sundell.

Automatically retrying an asynchronous Swift Task

Published on 25 Jan 2022
Discover page available: Concurrency

Sometimes, we might want to automatically retry an asynchronous operation that failed, for example in order to work around temporary network problems, or to re-establish some form of connection.

Here we’re doing just that when using Apple’s Combine framework to implement a network call, which we’ll retry up to 3 times before handling any error that was encountered:

struct SettingsLoader {
    var url: URL
    var urlSession = URLSession.shared
    var decoder = JSONDecoder()

    func load() -> AnyPublisher<Settings, Error> {
        urlSession
            .dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Settings.self, decoder: decoder)
            .retry(3)
            .eraseToAnyPublisher()
    }
}

Note that the above example will unconditionally retry our loading operation (up to 3 times) regardless of what kind of error that was thrown.

But what if we wanted to implement something similar, but using Swift Concurrency instead? While Combine’s Publisher protocol includes the above retry operator as a built-in API, neither of Swift’s new concurrency APIs offer something similar (at least not at the time of writing), so we’ll have to get creative!

One really neat aspect of Swift’s new concurrency system, and async/await in particular, is that it enables us to mix various asynchronous calls with standard control flow constructs, such as if statements and for loops. So, one way to implement automatic retries for await-marked calls would be to place the asynchronous code that we want to run within a loop that iterates over a range, which in turn describes how many retries that we wish to perform — like this:

struct SettingsLoader {
    var url: URL
    var urlSession = URLSession.shared
    var decoder = JSONDecoder()

    func load() async throws -> Settings {
        // Perform 3 attempts, and retry on any failure:
        for _ in 0..<3 {
            do {
                return try await performLoading()
            } catch {
                // This 'continue' statement isn't technically
                // required, but makes our intent more clear:
                continue
            }
        }

        // The final attempt (which throws its error if it fails):
        return try await performLoading()
    }

    private func performLoading() async throws -> Settings {
        let (data, _) = try await urlSession.data(from: url)
        return try decoder.decode(Settings.self, from: data)
    }
}

The above implementation works perfectly fine, but if we’re looking to add the same kind of retrying logic in multiple places throughout a project, then it might be worth moving that code into some form of utility that could be easily reused.

One way to do just that would be to extend Swift’s Task type with a convenience API that lets us quickly create such auto-retrying tasks. Our actual logic can remain almost identical to what it was before, but we’ll parameterize the maximum number of retries, and we’ll also add support for cancellation as well:

extension Task where Failure == Error {
    @discardableResult
    static func retrying(
        priority: TaskPriority? = nil,
        maxRetryCount: Int = 3,
        operation: @Sendable @escaping () async throws -> Success
    ) -> Task {
        Task(priority: priority) {
            for _ in 0..<maxRetryCount {
                try Task<Never, Never>.checkCancellation()

                do {
                    return try await operation()
                } catch {
                    continue
                }
            }

            try Task<Never, Never>.checkCancellation()
            return try await operation()
        }
    }
}

That’s already a really useful, and completely reusable implementation, but let’s take things one step further, shall we?

When retrying asynchronous operations, it’s very common to want to add a bit of delay between each retry — perhaps in order to give an external system (such as a server) a chance to recover from some kind of error before we make another attempt at calling it. So let’s also add support for such delays, which can easily be done using the built-in Task.sleep API:

extension Task where Failure == Error {
    @discardableResult
    static func retrying(
        priority: TaskPriority? = nil,
        maxRetryCount: Int = 3,
        retryDelay: TimeInterval = 1,
        operation: @Sendable @escaping () async throws -> Success
    ) -> Task {
        Task(priority: priority) {
            for _ in 0..<maxRetryCount {
                do {
                    return try await operation()
                } catch {
                    let oneSecond = TimeInterval(1_000_000_000)
let delay = UInt64(oneSecond * retryDelay)
try await Task<Never, Never>.sleep(nanoseconds: delay)

                    continue
                }
            }

            try Task<Never, Never>.checkCancellation()
            return try await operation()
        }
    }
}

Note how we can now remove the checkCancellation call at the start of our for loop, since our Task.sleep call will automatically throw an error if the task was cancelled. To learn more about delaying Task instances, check out “Delaying an asynchronous Swift Task”.

If we wanted to, we could’ve also added the “semi-public” @_implicitSelfCapture attribute to our operation closure, which would give it the same implicit-self-capturing behavior as when passing a closure directly to the Task type itself.

However, that’s not really something that I recommend doing (given that underscored attributes can change at any point), so let’s instead wrap things up by refactoring the SettingsLoader example from before to instead use our new Task extension to perform its retries:

struct SettingsLoader {
    var url: URL
    var urlSession = URLSession.shared
    var decoder = JSONDecoder()

    func load() async throws -> Settings {
        try await Task.retrying {
            let (data, _) = try await urlSession.data(from: url)
            return try decoder.decode(Settings.self, from: data)
        }
        .value
    }
}

Very nice! Note how we can use a given task’s value property to observe its returned value (or re-throw any error that was thrown within the task itself).

Conclusion

There are of course lots of ways that we could take this article’s Task extension further — for example by making it possible to only retry on certain errors (perhaps by enabling us to pass something like an retryPredicate closure when creating our task?) — but I hope that this article has given you a few ideas on how you could implement auto-retrying tasks within your projects.

If you have any questions, comments, or feedback, then feel free to reach out via either Twitter or email.

Thanks for reading!