I wrote a function async(timeoutAfter:work:)
. Its goal is to run an async task with a timeout. If the timeout expires and the work hasn't completed, it should cancel the task and throw a TimedOutError
.
Here’s my code. It seems to work, but I’m not sure if it’s correct. Am I on the right track here or is there a better way to achieve this?
import Foundation.NSDate // for TimeInterval
struct TimedOutError: Error, Equatable {}
/// Runs an async task with a timeout.
///
/// - Parameters:
/// - maxDuration: The duration in seconds `work` is allowed to run before timing out.
/// - work: The async operation to perform.
/// - Returns: Returns the result of `work` if it completed in time.
/// - Throws: Throws ``TimedOutError`` if the timeout expires before `work` completes.
/// If `work` throws an error before the timeout expires, that error is propagated to the caller.
func `async`<R>(
timeoutAfter maxDuration: TimeInterval,
do work: @escaping () async throws -> R
) async throws -> R {
return try await withThrowingTaskGroup(of: R.self) { group in
// Start actual work.
group.async {
return try await work()
}
// Start timeout child task.
group.async {
await Task.sleep(UInt64(maxDuration * 1_000_000_000))
try Task.checkCancellation()
// We’ve reached the timeout.
throw TimedOutError()
}
// First finished child task wins, cancel the other task.
let result = try await group.next()!
group.cancelAll()
return result
}
}
Here’s a usage example. You can set the sleep amount in the await Task.sleep(100_000_000)
to a higher value, e.g. 300_000_000
, to see it fail.
(If you want to try this out with Xcode 13.0 beta 1, use this workaround to make Task.sleep
work.)
detach {
do {
let favoriteNumber: Int = try await async(timeoutAfter: 0.25) {
await Task.sleep(100_000_000)
return 42
}
print("Favorite number: \(favoriteNumber)")
} catch {
print("Error: \(error)")
}
}
(Edit: Modified the code slightly to accommodate tasks that can throw.)