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.
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.