Error Propagation Patterns

I'm looking for some suggestions for propagating / converting errors at different levels of the application.

Might a bit of a contrived example... but let's if I manage to convey it:

Let's say I have an app that fetches some data and displays it on the screen. At several places along the process here I might end up with errors specific to the operations performed:

  • networking errors
  • JSON parsing errors
  • model logic errors

My networking client might define its errors as

enum NetworkError: Error {
      case timeout, etc. etc.
}

My json parsing might have its own errors as well:

enum JSONError: Error {
      case missingKey
}

my model might say:

enum ValidationError: Error {
    case logicError
}

I might then have a function that fetches the data (potential NetworkError), parses it (potential JSONError) and validates it (ValidationError).

Is the right way to model this is as one error type?

enum APIError {
   case timeout, 
   case jsonMissingKey
   case logicError
}

In this solution, the user of has to handle all these errors. And it feels strange because they the error cases don't feel like the belong grouped as one type.

maybe group them?

enum APIError {
   case netowrk(NetworkError)
   case json(JSONError)
   case validation(ValidationError)   
}

What are some solutions to modeling this in a way where I don't lose the actual error between layers, but maybe potentially avoid exposing all of them to the user?

As long as you expose your error handling as using Error instances, and implement appropriate descriptions, users don't need to know what error types you actually produce until they need to take particular action based on the particular error.

In Alamofire we encapsulate all of our internal errors using the AFError type, which has a variety of underlying types to separate different types of errors. However, all of our errors are exposed as Error, both so users don't have to care about AFError but also because we return underlying system errors directly. We have good (enough) descriptions for all of our errors so users can see what when wrong just by printing the error without having to know what type it is. If they do want to take some particular action we offer the asAFError: AFError? extension on Error so that they can pull that value out more easily. We also offer a variety of Bool properties to make it easy to check for a precise type of error.

2 Likes

I sometimes use the last pattern you mentioned, but only when I expect client code to want to catch and handle errors, and I have documented exactly which errors to expect:

enum ExpectedErrors {
    case underlyingA(UnderlyingA)
    case underlyingB(UnderlyingB)
}

When using that pattern, I often also return a Result intead of using throws, since the possible errors are then self‐documented by the API itself. (Clients can still tag .get() on the end to use it as if it were a method that throws.)

But in the vast majority of cases—and especially if the lower API makes no guarantees about which errors it throws—then I don’t bother wrapping them for two reasons:

  1. The underlying errors usually explain themselves for the most part. (Though I make sure the localizedDescription is reasonable on any new error types I create myself.)

  2. Catching code with as becomes more confusing if the same error might be nested inside other ones:

    do {
        something()
    } catch let error as NoMemoryError {
        // Oops. Missed it because it was actually a
        // NetworkingError.noMemory(NoMemoryError).
        emptyCache()
        tryAgain()
    } catch {
        fatalError("Cannot recover.")
    }
    

In these cases I just try and let the errors propagate themselves. (Or re‐throw them unaltered from a catch statement.)

1 Like

There are mainly three approaches here that I can think of:

  • Just propagate Error and hide the underlying error
  • Propagate an Either of sorts with all underlying errors (which is basically your second example)
  • Craft a different error for this other layer, based on the actual context of the callers of the method. e.g.
enum APIError: Error {
  case recoverable, unrecoverable
  init(_ error: NetworkError) { self = .recoverable }
  init(_ error: JSONError) { self = .unrecoverable }
  init(_ error: ValidationError) { self = .unrecoverable }
}
1 Like

This is awesome. Thanks everyone, got some different solutions to consider :slightly_smiling_face: