Handling Errors Asynchronously in Swift: Introducing ErrorStream
Errors are an inevitable part of any application. Properly handling and communicating these errors is crucial for creating a resilient and user-friendly application. With its robust type system and error-handling mechanisms, Swift has always made managing errors relatively straightforward. But new patterns emerge as we migrate into a Swift Concurrency.
I am glad to introduce the ErrorStream concept, a service designed to handle and stream errors asynchronously.
If you like my content and want to support me, please buy me a coffee and subscribe!
Also, I have the Telegram channel Swift in UA, where you can find more exciting things.
Motivation
General Swift error handling can be implemented with Result
type or/and do-catch syntax.
enum NetworkError: Error {
case invalidURL
case requestFailed
}
func fetchData(from url: String) -> Result<Data, NetworkError> {
.....
}
let result = fetchData(from: "someURL")
switch result {
case .success(let data):
print("Data fetched successfully: \(data)")
case .failure(let error):
print("Error occurred: \(error)")
}
In the above example, the fetchData
function returns a Result
that either contains Data
in case of success or a NetworkError
in case of failure.
enum FileError: Error {
case fileNotFound
case readError
}
func readFile(named name: String) throws -> String {
.....
}
do {
let content = try readFile(named: "someFile")
print("File content: \(content)")
} catch FileError.fileNotFound {
print("Error: File not found")
} catch {
print("Unknown error occurred")
}
In the above example, the readFile
function throws an error if the filename is not "validFile"
. The error is then caught and handled using the do-catch
block.
Both Result
and do-catch
in Swift provide clear and explicit mechanisms to handle errors, making the code more maintainable and easier to understand.
But in some cases, it can be necessary to handle errors in one particular place in your app (such as for showing notifications or logging errors), and it might be challenging to collect errors across the whole app.
Broadcasting errors become a challenge. What if we want multiple parts of our application to respond to a single error event?
For instance, you might want a logging system, a UI component, and a metrics tool to respond when a particular error happens. Rather than individually notifying each component, we need a mechanism to broadcast this error to all interested listeners. This is where ErrorStream shines.
How The ErrorStream Works
Using the ErrorStream is pretty straightforward.
//Adding errors
ErrorStream.add(MyError.someError)
//Listening for updates
Task {
for await error in ErrorStream.updates {
print("Received error: \(error)")
}
}
Because the updates
AsyncStream
is a AsyncSequence
behind the scenes, you can interact with it similarly.
Task {
for await (date, error) in ErrorStream.updates.map({ (Date(), $0.localizedDescription) }) {
print(date)
print(error)
}
}
You can iterate only with the specific error types.
Task {
for await error in ErrorStream.updates where error is MyErrorType {
print(error)
}
}
Finally, you can listen to errors in multiple tasks simultaneously.
//Errors to display
Task {
for await error in ErrorStream.updates where error is DisplayError {
showError(error)
}
}
//Errors to log
Task.detached {
for await error in ErrorStream.updates {
loggingService.log(error)
}
}
WARNING!
Avoid calling
ErrorStream.updates
not in theTask
context because it produces one more unmanaged continuation inside theErrorStream
.
Dive Into ErrorStream
The core idea behind ErrorStream
is using an AsyncStream
of Error
combined with the power of NSLock
for thread safety.
struct ErrorStream {
private static var lock = NSLock()
private static var continuations: [UUID: AsyncStream<Error>.Continuation] = [:]
...
}
The lock
ensures that we avoid running into race conditions, especially when multiple tasks might be trying to add errors or subscribe to the stream simultaneously.
The continuations dictionary keeps track of all active subscribers or listeners. The UUID
serves as a unique identifier, ensuring each subscriber has a distinct entry.
Adding an error to the stream is straightforward:
static func add(_ error: Error) {
lock.withLock {
for continuation in continuations.values {
continuation.yield(error)
}
}
}
When an error is added, it’s broadcast to all active listeners. The lock.withLock
ensures that this operation is thread-safe.
To subscribe and listen to these error events, you’d use:
static var updates: AsyncStream<Error> {
...
}
The updates
property returns an AsyncStream<Error>
. This allows any part of your application to listen for errors and react accordingly.
ErrorStream Full Code
/// `ErrorStream` is a utility to handle and stream error events asynchronously.
///
/// ## Usage:
///
/// ```swift
/// // Add an error to the stream
/// ErrorStream.add(MyError.someError)
///
/// // Listen for updates in the stream
/// Task {
/// for await error in ErrorStream.updates {
/// print("Received error: \(error)")
/// }
/// }
/// ```
struct ErrorStream {
/// A private static `NSLock` to ensure thread safety when accessing the continuations dictionary.
private static var lock = NSLock()
/// A private static dictionary mapping a `UUID` to an `AsyncStream<Error>.Continuation`.
/// This is used to track all the active continuations.
private static var continuations: [UUID: AsyncStream<Error>.Continuation] = [:]
/// Adds a new error to all active continuations.
/// This broadcasts the error to all listeners.
///
/// - Parameter error: The error object that is to be added to the stream.
///
/// ## Usage:
///
/// ```swift
/// ErrorStream.add(MyError.someError)
/// ```
static func add(_ error: Error) {
lock.withLock {
for continuation in continuations.values {
continuation.yield(error)
}
}
}
/// An asynchronous stream that provides updates for new errors.
/// Subscribers can listen to this stream to receive real-time error updates.
///
/// ## Usage:
///
/// ```swift
/// Task {
/// for await error in ErrorStream.updates {
/// print("Received error: \(error)")
/// }
/// }
/// ```
static var updates: AsyncStream<Error> {
AsyncStream<Error> { continuation in
let id = UUID()
lock.withLock {
continuations[id] = continuation
}
continuation.onTermination = { _ in
lock.withLock {
continuations.removeValue(forKey: id)
return
}
}
}
}
}
Conclusion
ErrorStream
provides a robust and straightforward way to broadcast error events in an asynchronous Swift environment. By leveraging the power of AsyncStream
and ensuring thread safety with NSLock
, it offers a pattern that can be invaluable in complex, asynchronous applications.
While ErrorStream
is a foundation, it can be extended further to support different error types or integrate seamlessly with logging and monitoring tools.
Next time you find yourself in need of broadcasting errors in your Swift application, consider giving ErrorStream
a try!