【Swift】Timer内だとエラーがthrowされない件と解決法

## 概要
一定間隔で特定の処理を行うために、Timer を使用したコードを書きました。以下の例では、非同期のメソッド fetchData をタイマー内で呼び出し、エラーをキャッチしようとしています。

```Swift
enum SampleError: Error {
    case sample
}

final class ViewModel: @unchecked Sendable {
    private var timer: Timer?
    
    func startTimer() throws {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            Task {
                try await self?.fetchData()
            }
        }
    }
    
    private func fetchData() async throws {
        throw SampleError.sample
    }
}
```

しかし、実際にこのコードを使用してみると、以下のようにエラーをキャッチできませんでした。

```Swift
let viewModel = ViewModel()

do {
    try viewModel.startTimer()
} catch {
    print(error) // エラーが流れてこない
}
```

本記事はこのエラーが伝播してこない原因を調べたので簡単にまとめた備忘録です。

## 原因
### 1. 非同期処理のエラーは直接外部に伝播しない

上記のコードの場合、timer処理の中で `Task` を用いて非同期処理を行なってますが、そこで発生したエラはタスクのスコープ内に留まり、外部には直接伝播しません。

この性質は次のコードからも確認できます。

**エラーが外部に伝播しない例**
```Swift
final class ViewModel: @unchecked Sendable {
    private var timer: Timer?
    
    private func fetchData() async throws {
        throw SampleError.sample
    }
    
    func sampleTask() throws {
        Task {
            try await fetchData()
        }
    }
}

let viewModel = ViewModel()

do {
    try viewModel.sampleTask()
} catch {
    print(error) // エラーは流れてこない
}
```

このコードでは `sampleTask`のTask 内部でエラーが発生しても、呼び出し元でキャッチすることはできません。

---

**エラーをキャッチできる例**
一方で、Task 全体を do-catch 内に含めると、同じスコープ内でエラーを処理できるようになります。
```Swift
final class ViewModel: @unchecked Sendable {
    private var timer: Timer?
    
    private func fetchData() async throws {
        throw SampleError.sample
    }
    
    func sampleTask() async throws {
        try await fetchData()
    }
}

let viewModel = ViewModel()

Task {
    do {
        try viewModel.sampleTask()
    } catch {
        print(error) // エラーを受け取れる
    }
}
```

### 2. Timer のクロージャが throws をサポートしていない
例えばなんとかして、fetchDataをasyncメソッドではなくすとします。そうすると以下のように記述できるようになります。
```Swift
final class ViewModel: @unchecked Sendable {
    private var timer: Timer?
    
    func startTimer() throws {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            try self?.fetchData()
        }
    }
    
    private func fetchData() throws {
        throw SampleError.sample
    }
}
```
しかしこれだとコンパイルエラーが発生します。
`scheduledTimer`のシグネチャを見てみると以下のようになっています。
```Swift
class func scheduledTimer(
    withTimeInterval interval: TimeInterval,
    repeats: Bool,
    block: @escaping @Sendable (Timer) -> Void) -> Timer
)
```

これをみるとわかる通り、そもそもscheduledTimerに渡せるクロージャには`throws`が含まれていないため、エラーを返す可能性のあるクロージャを渡すことはできません。

このことからtimer内で発生したエラーを外側へ伝播させるには、特殊な方法が必要だということになります。

## 解決法
### 解決策1: メソッド引数でエラー時のクロージャを渡す

1つ目の解決法はメソッドの引数にエラー時のクロージャを渡すというものです。
```Swift
final class ViewModel: @unchecked Sendable {
    private var timer: Timer?
    
    func startTimer(onError: @Sendable @escaping (Error) -> Void) {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            do {
                try self?.fetchData()
            } catch {
                onError(error)
            }
        }
    }
    
    private func fetchData() throws {
        throw SampleError.sample
    }
}

let viewModel = ViewModel()

viewModel.startTimer { error in
    print(error)
}
```
このようにすれば簡単にエラー時の処理を記述できます。

### 解決策2. AsyncThrowingStreamを使う
Timer内でエラーをthrowできないように設計されているのであれば、そもそもTimer内でエラーを返す可能性のある関数を実行すべきではないのかもしれません。

そこで2つ目の解決策は、Timerの代わりに`AsyncThrowingStream`を使うというものです。

```Swift
final class ViewModel: @unchecked Sendable {
    private var timerTask: Task<Void, Never>?
    
    func startTimer() -> AsyncThrowingStream<Void, Error> {
        AsyncThrowingStream { [weak self] continuation in
            guard let self else {
                continuation.finish()
                return
            }
            
            timerTask = Task { [weak self] in
                guard let self else { return }
                
                while !Task.isCancelled {
                    do {
                        try fetchData()
                    } catch {
                        continuation.finish(throwing: error)
                        break
                    }
                    
                    try? await Task.sleep(nanoseconds: 1_000_000_000 * 1)
                }
            }
            
        }
    }
    
    private func fetchData() throws {
        throw SampleError.sample
    }
}

let viewModel = ViewModel()

Task {
    do {
        let stream = viewModel.startTimer()
        for try await _ in stream {
            
        }
    } catch {
        print(error)
    }
}
```

Task、Streamのキャンセルに気を配る必要はありますが、`fetchData`で何かしらのレスポンスがある場合はこちらの方が便利だと思います。

## まとめ
- **エラーが外部に伝播しない理由:**
    1. Task 内のエラーはスコープ外には伝播しない
    1. Timer のクロージャは throws をサポートしていない
- **解決策:**
    1. エラー時のクロージャを渡す方法
    1. AsyncThrowingStream を使った非同期処理の設計
    
エラーを外部に伝播させたい場合は、これらの解決策を使い、設計に応じた柔軟なエラーハンドリングを実現してください。