swiftasync-awaitconcurrency

How to add a timeout to an awaiting function call


What's the best way to add a timeout to an awaiting function?

Example:

/// lets pretend this is in a library that I'm using and I can't mess with the guts of this thing
func fetchSomething() async -> Thing? {
    // fetches something
}

// if fetchSomething() never returns then doSomethingElse() is never ran. Is there anyway to add a timeout to this system?
let thing = await fetchSomething()
doSomethingElse()

I wanted to make the system more robust in the case that fetchSomething() never returns. If this was using combine, I'd use the timeout operator.


Solution

  • One can create a Task, and then cancel it if it has not finished in a certain period of time. E.g., launch two tasks in parallel:

    // cancel the fetch after 2 seconds
    
    func fetchThingWithTimeout() async throws -> Thing {
        let fetchTask = Task {
            try await self.fetchThing()                           // start fetch
        }
    
        let timeoutTask = Task {
            try await Task.sleep(for: .seconds(2))                // timeout in 2 seconds
            fetchTask.cancel()
        }
    
        return try await withTaskCancellationHandler {            // handle cancelation by caller of `fetchThingWithTimeout`
            let result = try await fetchTask.value
            timeoutTask.cancel()
    
            return result
        } onCancel: {
            fetchTask.cancel()
            timeoutTask.cancel()
        }
    }
    
    // here is a random mockup that will take between 1 and 3 seconds to finish
    
    func fetchThing() async throws -> Thing {
        let duration: TimeInterval = .random(in: 1...3)
        try await Task.sleep(for: .seconds(duration))
        return Thing()
    }
    

    If the fetchTask finishes first, it will reach the timeoutTask.cancel and stop it. If timeoutTask finishes first, it will cancel the fetchTask.

    Obviously, this rests upon the implementation of the fetchThing function. It should not only detect the cancelation, but also throw an error (likely a CancellationError) if it was canceled. We cannot comment further without details regarding the implementation of fetchTask.

    For example, in the above example, rather than returning an optional Thing?, I would instead return Thing, but have it throw an error if it was canceled.


    Note, that withTaskCancellationHandler is required because we used unstructured concurrency, where cancelation is not automatically propagated for us. We have to handle that manually. You can, alternatively, remain within structured concurrency using a task group:

    func fetchThingWithTimeout() async throws -> Thing {
        try await withThrowingTaskGroup(of: Thing.self) { group in
            group.addTask {
                try await self.fetchThing()                       // start fetch
            }
    
            group.addTask {
                try await Task.sleep(for: .seconds(2))            // timeout in 2 seconds
                throw CancellationError()
            }
    
            guard let value = try await group.next() else {       // see if fetch succeeded …
                throw FetchError.noData                           // theoretically, it should not be possible to get here (as we either return a value or throw an error), but just in case
            }
    
            group.cancelAll()                                     // … but if we successfully fetched a value, cancel the timeout task, and
            return value                                          // … return value
        }
    }
    

    I hesitate to mention it, but while the above assumes that fetchThing was well-behaved (i.e., cancelable), there are permutations on the pattern that work even if it does not (i.e., run doSomethingElse in some reasonable timetable even if fetchThing “never returns”).

    But this is an inherently unstable situation, as the resources used by fetchThing cannot be recovered until it finishes. Swift does not offer preemptive cancelation, so while we can easily solve the tactical issue of making sure that doSomethingElse eventually runs, if fetchThing might never finish in some reasonable timetable, you have deeper problem.

    You really should find a rendition of something that is cancelable, if it is not already.