Skip to content

Instantly share code, notes, and snippets.

@christianselig
Created October 29, 2025 20:00
Show Gist options
  • Select an option

  • Save christianselig/2eeba611fcc46e560584f9ea966eedd2 to your computer and use it in GitHub Desktop.

Select an option

Save christianselig/2eeba611fcc46e560584f9ea966eedd2 to your computer and use it in GitHub Desktop.
import SwiftUI
struct ContentView: View {
let downloader = Downloader()
var body: some View {
VStack {
Text("Hello world")
Button("Download") {
Task {
do {
// Give up after 2 seconds
print(try await downloader.download(timeout: .seconds(2)))
} catch {
print("Button download error: \(error)")
}
}
}
}
.task {
do {
// Take as long as you need
print(try await downloader.download(timeout: .seconds(2)))
} catch {
print("Immediate download error: \(error)")
}
}
}
}
actor Downloader {
private var cached: Data?
private var existingTask: Task<Data, Error>?
func download(timeout: Duration? = nil) async throws -> Data {
if let cached { return cached }
if let existingTask {
return try await awaitExistingTask(existingTask, timeout: timeout)
}
let url = URL(string: "https://christianselig.com/dinosaur.mp4")!
let task = Task<Data, Error> {
let request = URLRequest(
url: url,
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData
)
let (data, _) = try await URLSession.shared.data(for: request)
self.cached = data
self.existingTask = nil
return data
}
self.existingTask = task
return try await awaitExistingTask(task, timeout: timeout)
}
private func awaitExistingTask(
_ task: Task<Data, Error>,
timeout: Duration?
) async throws -> Data {
if let timeout {
return try await withTimeout(timeout) {
return try await task.value
}
} else {
return try await task.value
}
}
}
enum TimeoutError: Error { case timedOut }
func withTimeout<T>(
_ interval: Duration,
operation: @escaping () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await Task.sleep(for: interval)
throw TimeoutError.timedOut
}
group.addTask {
return try await operation()
}
defer { group.cancelAll() }
return try await group.next()!
}
}
@donnywals
Copy link

it's a bit quick and dirty but this should do what you want

actor Downloader {
    private var cached: Data?
    // we're only caching this as a way to avoid adding multiple timeout tasks
    // if you want to allow someone passing 2, then 1, and to timeout after 1 you could
    // remove this
    private var timeoutTask: (() async throws -> Data)?
    private var timeoutTaskGroup: ThrowingTaskGroup<Data, Error>?
    
    func download(timeout: Duration? = nil) async throws -> Data {
        if let cached { return cached }
        
        // we're already downloading, add timeout to group if needed
        // await the group's first result
        if var timeoutTaskGroup {
            if let timeout, timeoutTask == nil {
                timeoutTaskGroup.addTask {
                    try await Task.sleep(for: timeout)
                    throw TimeoutError.timedOut
                }
            }
            
            return try await timeoutTaskGroup.next()!
        }
        
        // not yet downloading, create task group and return its result
        return try await withThrowingTaskGroup { group in
            self.timeoutTaskGroup = group
            
            group.addTask {
                return try await self.performDownload()
                
            }
            
            // add timeout if needed
            if let timeout {
                self.timeoutTask = {
                    try await Task.sleep(for: timeout)
                    throw TimeoutError.timedOut
                }
                group.addTask {
                    try await self.timeoutTask!()
                }
            }
            
            defer {
                self.timeoutTaskGroup = nil
                self.timeoutTask = nil
                group.cancelAll()
            }
            
            return try await group.next()!
        }
    }
    
    private func performDownload() async throws -> Data {
        print("download started")
        let url = URL(string: "https://christianselig.com/dinosaur.mp4")!
        
        let request = URLRequest(
            url: url,
            cachePolicy: .reloadIgnoringLocalAndRemoteCacheData
        )
        do {
            let (data, _) = try await URLSession.shared.data(for: request)
            self.cached = data
            print("done!")
            return data
        } catch {
            print(error)
            throw error
        }
    }
}

enum TimeoutError: Error { case timedOut }

@donnywals
Copy link

nvm I think I misunderstood the question. Will try and cook up an alternative in a bit

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment