Jun 7, 2019

Error Handling Guidelines

After some recommendations on localization, I’d like to discuss another complex topic, error handling. Also I’m a big fan of standard Apple’s Logging APIs, so the post will touch them a bit as well. Again, I hope the proposed guidelines will be useful for your own projects.

Logging API

I’m using os_log for all events and user actions in NativeConnect. Apple’s native API does not require any external dependencies, has got built-in privacy and is extremely easy to use. After working with it for a year now, I find the following approach the most convenient:

// NativeKit.framework: KeychainStore.swift
private let events: OSLog = .init(subsystem: "com.shpakovski.NativeKit.Events", category: "KeychainStore")

// NativeKit.framework: MainThreadWatchdog.swift
private let events: OSLog = .init(subsystem: "com.shpakovski.NativeKit.Events", category: "MainThreadWatchdog")

// NativeConnect.app: AccountViewController.swift
private let events: OSLog = .init(subsystem: "com.shpakovski.NativeConnect.Events", category: "AccountViewController")
private let userActions: OSLog = .init(subsystem: "com.shpakovski.NativeConnect.UserActions", category: "AccountViewController")

The handles above are used in the same files where they are declared. Equal subsystems are convenient when you want to follow a particular flow, while different categories are helpful when you want to disable or filter out selected scopes of the app.

Where to Log Errors

In Swift, it’s very tempting to just throw an error and rely on the call-side to handle it. So usually we end up with code like this:

// NativeKit.framework: KeychainStore.swift
guard status == errSecSuccess else {
  throw KeychainError.cannotSaveModel(SecCopyErrorMessageString(status, nil) as String?)
}

// NativeConnect.app: AccountViewController.swift
@IBActions func save(_ sender: Any?) {
  os_log(.info, log: userActions, "Save Account “%s”", account.identifier)
  try {
    try keychainStore.saveModel(account)
  } catch {
    os_log(.error, log: events, "Cannot save Account: %{public}s", String(describing: error))
    if presentError(error) { return }
    os_log(.error, log: events, "%{public}s cannot present an error", String(describing: self))
  }
}

This works but makes it more difficult to investigate into such errors, because their logs lack any context. Indeed something wrong happened within KeychainStore.saveModel(_:), but we have only an error from there, and the source of the problem may be buried very deep into the throwing method.

My suggestion here is very simple: always log the details as close to the source of a problem as possible. So just before you throw an error, call os_log with all the details necessary for the future investigation:

let systemMessage = SecCopyErrorMessageString(status, nil) as String?
os_log(.error, log: events, """
  SecItemUpdate cannot save the model “%s”: %{public}s
  """, identifier, String(describing: systemMessage))
throw KeychainError.cannotSaveModel(systemMessage)

The difference may look subtle, but now we have an additional log entry with a precise location in the project source code. This is even more helpful when you have paths with a similar business logic, or when you reuse localized error messages with the same text in multiple places.

How to Log Errors

You probably noticed that code above contains specifiers %{public}s and %s treated as %{private}s. Let’s remember that all information printed by the app will go straight into the Console. Moreover, error messages, unlike information and debug entries, will be persisted. So we should be extremely careful about privacy:

  1. Any parameters with sensitive information must go with a default private identifier.
  2. Safe information useful for debugging and analytics can be logged using a public specifier.
  3. Short error messages work perfectly, because os_log will remember the entry location in the project source code.
os_log(.info, log: userActions, "Open “%s”",
  String(describing: document.fileURL.path))

os_log(.info, log: userActions, "Select “%{public}s”",
  selectedLanguage.code)

os_log(.errors, log: events, "Cannot save “%s”: %{public}s",
  String(describing: document.fileURL.path),
  String(describing: error))

How to Create Errors

Apple recommends to add many enum types to the project e.g. DocumentError, KeychainError etc. This approach is very problematic in production however. For every added case, you should provide a generic localized description and sometimes custom parameters. In practice though, you are trying to reuse the same descriptions and avoid adding new cases, which results in a bad UX.

As an alternative, I recommend to introduce just a single type as a struct compatible with your own design. In the end of the day, all errors are just text messages for a banner, a notification or an alert sheet. As a fantastic side-effect, once you stop looking for an enum appropriate for a particular problem, you will have more time to write better user-facing messages!

These are minimal requirements for a custom throwable error:

  1. It should not include an alert title, because most of the time a caller knows better what went wrong in general. Usually the view controller provides a more appropriate title when the error is presented as a sheet or a banner.
  2. It must contain a localized human-readable message, with as much context and sensitive information as possible. By the way, this message should have a trailing dot according to HIG.
  3. Also, it is very helpful to provide an information with a location of the original problem in the source code, for debugging purposes.
  4. Finally, the error may provide a short event string without any private data for Analytics! Then you will know which parts of the app cause more problems than others.

Here is an example:

public extension InternalError {
  public init(analyticsMessage: String?, localizedMessage: String?,
    file: String = #file,
    function: String = #function,
    line: UInt = #line)
}

// ...

guard libraryPackage.isDirectory else {
  let filename = String(describing: libraryPackage.filename)
  os_log(.error, log: events,
    "Library package “%s” is not a directory", filename)
  let alertMessage = errorLocalizer
    .stringWithFormat("package-is-not-directory-\(filename)")
  throw InternalError(analyticsMessage: "Package is not directory",
    localizedMessage: alertMessage)
}

Note that InternalError.init catches a location of the same guard block that creates it.

How to Catch Errors

  1. Ideally, the catch block should recover to some initial or new state using data provided by the error itself, without any user interaction.
  2. You may present an alert sheet with custom buttons or popup a red banner without actions. The alert title looks best when it’s short and does not have a trailing dot.
  3. The handler may also report an error to your Analytics engine.
  4. You should avoid logging errors from your own code, because, as we discussed above, they were already logged.
  5. Vice-versa, you should always log Foundation and Cocoa errors e.g. from the File System or from the Core Data.
  6. The same rules are applied to completion handlers for asynchronous operations.
  7. Sometimes a log entry works as a natural error handler, by design, i.e. there’s no need to present an alert sheet.

And that’s it. Hopefully this article will give you ideas how to improve error handling.

Thanks for reading!