Structured Concurrency: Error handling patterns

Structured Concurrency: Error handling patterns

Non-obvious flaw in thrown pattern

ยท

7 min read

The new concurrency paradigm in Swift opens exciting opportunities to enhance the existing codebase, ensuring multithreading operations' safety and predictability. While some flows become easy and straightforward, others can bring new challenges and problems.

Context

Let's take a look at the following situation:

  1. You need to obtain the current user location from some supposed LocationService

  2. Call to this service can return location(happy path) OR can return ERRORS

  3. Many things can go wrong: location is disabled on the device, GPS is not available at the moment, and the user declined to give your app needed permissions or something else.

  4. While some errors can be only logged, others should be processed in a specific way. For example, if the user disabled the location for your app you need to notify a user about this situation and ask him to go to Settings and enable it.

Throwing pattern

Following Apple guides, we need to implement an async function in LocationService that can throw errors. Its signature can be represented like this:

func getCurrentUserLocation() async throws -> CLLocation

I also want to provide some minimal infrastructure to present LocationService in a realistic manner.

First of all, we need an instance of CLLocationManager created in a safe manner using @MainActor attribute.

import CoreLocation

class LocationService {
    private let logger = Logger(subsystem: "com.testproject.app", category: "LocationService")

    // CLLocationManager should be always accessed on the same thread 
    // where it was created. It is easy to use MainActor for this
    @MainActor private lazy var manager: CLLocationManager = {
        logger.debug("manager: \(Thread.current)")
        let manager = CLLocationManager()
        manager.delegate = delegateAdapter
        return manager
    }()

    @MainActor private var isAuthorized: Bool {
        manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse
    }
}

The main actor ensures that the manager instance is created and accessed from the main thread. The Logger is added to check this fact.

Secondly, we need some black-boxed adapter(I don't want to introduce unnecessary details) for CLLocationManagerDelegate that can adapt delegate calls into async functions:

class FancyLocationDelegateAdapter: NSObject, CLLocationManagerDelegate {
    func handleLocation() async throws -> CLLocation {
        // Somehow call manager, listen for callback and return result
    }
}

Finally, it makes sense to introduce enum for errors so we can easily handle them in calling code:

enum LocationServiceError: Error {
    case unauthorized // all other problems
    case manuallyDisabled // when used manually denied permissions
}

At this point, we can use all previous code and define our get function:

class LocationService {
    private let delegateAdapter = FancyLocationDelegateAdapter()

    @MainActor private lazy var manager: CLLocationManager { ... }
    @MainActor private var isAuthorized: Bool { ... }

    func getCurrentUserLocation() async throws -> CLLocation {
        logger.debug("get location on: \(Thread.current)")
        guard await isAuthorized else {
            if await manager.authorizationStatus == .denied {
                throw LocationServiceError.manuallyDisabled
            } else {
                throw LocationServiceError.unauthorized
            }
        }

        return try await delegateAdapter.handleLocation()
    }
}

Now it is time to call it in our demo LocationViewModel :

class LocationViewModel: ObservableObject {
    private let locationService: LocationService
    @Published private(set) var userLocation: CLLocation?

    init(locationService: LocationService) {
        self.locationService = locationService
    }

    func didAppear() {
        Task {
            userLocation = try await locationService.getCurrentUserLocation()
        }
    }
}
๐Ÿ’ก
Important to understand that Task consumes your thrown error without any warning and you can find it later only in the task itself: let task: Task<CLLocation, Error> = Task { ... }

Let's try to catch an error and provide proper action:

func didAppear() {
    Task {
        do {
            userLocation = try await locationService.getCurrentUserLocation()
        } catch let error as LocationServiceError {
            switch error {
            case .unauthorized:
                handleCommonError(error)
            case .manuallyDisabled:
                handleDeniedAccess()
            }
        }
    }
}

Looking carefully at this code we can notice that:

  1. The error type is lost: LocationServiceError was thrown but here we can only see Error โŒ

  2. Therefore you can't figure out what to catch without peeking into the class itself which means broken encapsulation โŒ

  3. As I said before Task can consume our error and mask that fact we need to catch ANOTHER generic error that possibly even DOESN'T exist! โŒ

Let's use a more convenient way of calling getCurrentUserLocation :

func didAppear() {
    Task {
        await updateLocation()
    }
}

private func updateLocation() async {
    do {
       userLocation = try await locationService.getCurrentUserLocation()
    } catch let error as LocationServiceError {
        switch error {
        case .unauthorized:
            handleCommonError(error)
        case .manuallyDisabled:
            handleDeniedAccess()
        }
    }
}

Error below is the illustration of problem #3 where we need to catch everything without knowledge of exact error types.

The best solution that we can invent here with the current pattern is:

private func updateLocation() async {
    do {
       userLocation = try await locationService.getCurrentUserLocation()
    } catch {
        switch error {
        case LocationServiceError.manuallyDisabled:
            handleDeniedAccess()
        default:
            handleCommonError(error)
        }
    }
}

Result pattern

Another way that we can follow is to avoid throwing errors when they matter and instead return Result<CLLocation, LocationServiceError>. Let's see what we can win(or lose here):

// Updating Adapter to support Result pattern
func reportLocation() async -> Result<CLLocation, Error>

func getCurrentUserLocation() async -> Result<CLLocation, LocationServiceError> {
        logger.debug("get location on: \(Thread.current)")
        guard await isAuthorized else {
            return .failure(
                await manager.authorizationStatus == .denied ? .manuallyDisabled : .unauthorized
            )

        }
        // Convert any adapter error into common .unauthorized error
        return await delegateAdapter.reportLocation().mapError { _ in .unauthorized }
    }

It is almost the same inside: a little bit more boilerplate with .failure/.success a little bit less code because of the typed error.

What can we see at the call site?

private func updateLocation() async {
    let result = await locationService.getCurrentUserLocation()
    switch result {
    case .success(let location):
        userLocation = location
    case .failure(let error):
        switch error {
        case .manuallyDisabled:
            handleDeniedAccess()
        case .unauthorized:
            handleCommonError(error)
        }
    }
}

It is definitely better to know what error you can receive but now we have a switch inside the switch. Not good to increase cyclomatic complexity for no reason.

However, there is another trick that we can use. Thanks to the article https://forums.swift.org/t/using-result-type-with-async-await/57003/4 we can go even further:

extension Result {
    func `catch`(_ failureClosure: (Failure) -> Void) {
        if case .failure(let failure) = self {
            failureClosure(failure)
        }
    }
}

private func updateLocation() async {
    await locationService.getCurrentUserLocation()
        .map { value in
            userLocation = value
        }
        .catch { error in
            switch error {
            case .manuallyDisabled:
                handleDeniedAccess()
            case .unauthorized:
                handleCommonError(error)
            }
        }
}

Here we use map as then in Javascript-like promise while adding catch to avoid mapError unnecessary type conversion.

  • The error type is preserved โœ…

  • Complexity hidden behind chained functions calls โœ…

  • Extra catch cases are not needed โœ…

Combine approach

To be complete in our tools overview I want to consider Combine for the same case.

Firstly, updating the service method to wrap around async code with Future(of course we can rewrite everything from scratch with Combine but I think the refactoring case is more interesting):

func getCurrentUserLocation() -> Future<CLLocation, LocationServiceError> {
    return Future { [weak self] promise in
        guard let self else { return }

        logger.debug("get location on: \(Thread.current)")
        Task {
            guard await self.isAuthorized else {
                promise(.failure(await self.manager.authorizationStatus == .denied ? .manuallyDisabled : .unauthorized))
                return
            }
            promise(await self.delegateAdapter.reportLocation().mapError { _ in .unauthorized })
        }
    }
}

Next, we will add similar to Result syntax sugar to Future:

extension Future {
    func sink(onSuccess: @escaping (Output) -> Void, onError: @escaping (Failure) -> Void) -> Cancellable {
        return sink(
            receiveCompletion: { completion in
                if case .failure(let error) = completion {
                    onError(error)
                }
            },
            receiveValue: onSuccess
        )
    }
}

Finally, updated call site:

import Combine

private var cancellables = Set<AnyCancellable>()

private func updateLocation() async {
    locationService.getCurrentUserLocation()
        .sink(onSuccess: { [weak self] location in
            self?.userLocation = location
        }, onError: { [weak self] error in
            switch error {
            case .manuallyDisabled:
                self?.handleDeniedAccess()
            case .unauthorized:
                self?.handleCommonError(error)
            }
        })
        .store(in: &cancellables)
}
  • Type ensured โœ…

  • Nested hell avoided โœ…

  • Dealing with subscriptions is always a pain for Combine. You need to store it somewhere and make sure that it will be deallocated at the time โŒ

Conclusion

  • There are many ways to handle errors, you can choose more suitable for the specific situation from the proposed above

  • Do not hesitate to write your extension to achieve easier access to the most common functions/patterns

  • async throws pattern works well when you don't care about errors and just want to chain some calls to receive values by several steps

  • async -> Result pattern is good when your error is also a kind of result and you will take actions based on concrete error type

  • Combine is always somewhere between. It can do everything, abstracting from async/sync, and chaining easily but comes with costs of complexity and sometimes verbosity

  • I'm looking forward to seeing the implementation of throws with the error specified in some future Swift version

History retrospective

https://nshipster.com/optional-throws-result-async-await/

ย