Handling Errors Asynchronously in Swift: Introducing ErrorStream

Ihor Malovanyi
5 min readOct 27, 2023

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 the Task context because it produces one more unmanaged continuation inside the ErrorStream.

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!

--

--

Ihor Malovanyi

iOS Software Engineer | Swift in UA founder | WWDC2018/2019 attendee