Effective iOS error management

How to manage errors across iOS apps and dependencies

 

Let’s begin with a story.

Once upon a time, there was a wonderful Princess App in the kingdom. The app was very complicated and full of very obscure parts, but it brought great joy to all who used it.
One day, however, an evil bug appeared and showed an ugly and unintelligible error code to the App users of the kingdom, as below.

error_alert
A brave prince developer was called to kill the dragon fix the bug, and the ticket he was given went more or less like this:
“The user can’t proceed with the purchase – sometimes a strange error appears, sometimes nothing happens.”
The brave developer picked up his keyboard and… ignored the error, deleted the entire codebase and rewrote everything in Swift 5.
No error code was ever seen again and they all lived happily ever after.
The end.
 
Unfortunately, there are no such fairy tales in real life; errors exist and as a developer your task is to understand the problem and fix it.
Now let’s take a look at the most common problems and deficiencies of how errors are generally handled in iOS apps.

Lost errors

When the app fails and nothing happens, this could mean that one or more errors have been lost at some point.
For example:

func getDataFromUrl(url: URL, completion:(_ data: Data?, _  response: URLResponse?, _ error: NSError?) -> Void) {
	...
	if (data) {
		// Do something
	}
	...
}

What has happened to the error?

Errors from Hell to Heaven

Imagine the software being designed in layers; we can think of the bottom layer as “hell” and the top layer, the closest to the user, as “heaven”.
The error shown in the screenshot is quite clearly not meant to be shown to the user. It is too technical and in most cases not localised. This happened because a low-level error has been propagated through the various app layers up to the UI.
For example:
 

func getDataFromUrl(url: URL, completion:(_ data: Data?, _ error: NSError?) -> Void) {
	...
	if (error) {
		// Show error in UIAlertcontroller
		//or
		//send it up with a delegate/completion block to the higher level of the app.
	}
	else if (data) {
		// Do something
	}
	...
}

 
What has happened here can be illustrated as below:
just-eat-error-handling
While some of the negative outcomes of this approach might appear evident, some others are more ambiguous.

  • The obvious: Ugly and unintelligible error messages appearing on the user’s screen

These error messages are clearly not meant for users. The only consequence of these being visible to users is a load of bad reviews in the App Store.

  • The ambiguous: Loss of crucial information

The same low-level (network/parsing/Core data) error can impact your app in many different ways. If only the original error is propagated, all the information in between is lost, not logged or tracked, and ultimately debugging the error becomes much more difficult.
These undesirable outcomes for the user and the integrity of the code can – and should be – avoided.

Effective error handling

Or, How to propagate errors in a standard and consistent way.
Error management is always an important part of any app, even more so if your app is fragmented into Internal Pods, Development Pods, Open Source Pods, Libraries, Utilities and Internal Frameworks. We’ll refer to these dependencies as Modules throughout the article.
At Just Eat we explored several options for our Swift/Objective-C codebase. In the end, we chose to stick with the classic evergreen NSError and NSUnderlyingErrorKey universe, an approach used especially in macOS and suggested by Apple since the very first years of the platform.
Before introducing the proposed solution, let’s go through few key concepts.

Key concepts

Anatomy of an NSError

As explained in the official documentation and in this article by NSHipster, an NSError object is composed of:

  • An error domain – A string representing the “Error context”, or where the error came from.
  • An error code – An Int value representing the error. It is the responsibility of the error creator to make it meaningful. Different domains can have the same error codes with completely different meanings.
  • A user info dictionary – A dictionary containing all the additional useful information about the error.

The Error Chain

An error chain is a linked list of NSErrors. Each error embeds the previous error in the user info dictionary using the standard key NSUnderlyingErrorKey. You can think of it as a standard linked list. This is illustrated below.
just-eat-error-handling-1

The Just Eat approach

Rationale

We have put together a set of guidelines to unify our approach to error management across all the app’s parts, modules and libraries.
The final result is a codebase that consistently

  • Propagates
  • Displays
  • Logs

any error generated across the app.
just-eat-error-handling-2
That means that the final error structure received from the app UI is something like:

{
    Domain: com.yourapp.UI
    Code: 3XXX
    ...
    UserInfo: {
        Domain: com.yourapp.managerX
        Code: 2XXX
        …
        UserInfo: {
            Domain: com.yourapp.apiLib
            Code: 1XXX
            ...
        	UserInfo: {
            	Domain: NSERLErrorDomain
            	Code: 403
            	...
        	}
        }
    }
}

This approach preserves all the information of the error chain and allows every app layer to make decisions and enrich the error information.

Error handling concepts

In order to achieve this result we decided to enforce some basic concepts:

Error builder

Each app and module has its own error builder, where the error domain is specified and the errors are created, using the pre-assigned error code range.

import ErrorUtilities
public let ModuleNameErrorDomain = "com.yourapp.modulename"
@objc public enum ModuleNameErrorCode: ErrorCode {
    case anErrorCase = X000
    ...
}
@objc public class ModuleNameErrorBuilder: NSObject, ErrorBuilder {
    public class func error(forCode code: ErrorCode) -> NSError {
        var userInfo = [String: Any]()
        userInfo[NSLocalizedDescriptionKey] = localizedDescriptionForCode(code: code)
        userInfo[NSLocalizedFailureReasonErrorKey] = failureReasonForCode(code: code)
        return NSError(domain: ApplePayErrorDomain, code: code, userInfo: userInfo)
    }
    private class func localizedDescriptionForCode(code: ErrorCode) -> String {
        switch code {
        case ModuleNameErrorCode.anErrorCase.rawValue:
            return NSLocalizedString("your localised error description", comment: "ModuleName")
        ...
    }
    private class func failureReasonForCode(code: ErrorCode) -> String {
        switch code {
        case ModuleNameErrorCode.anErrorCase.rawValue:
            return NSLocalizedString("your localised error failure reason", comment: "ModuleName")
        ...
    }
}

Mandatory error propagation

All the module interface methods that could report an error should be able to propagate it using any of the available techniques (for example using delegation, completion blocks, futures, Swift exceptions and so on).
When a module-A receives an error from module-B, the error needs to be preserved and module-A should create a new error encapsulating the underlying error, incrementing the error chain.

Errors and UI

Only the errors created in the UI level error builder (com.yourapp.ui domain) can be presented to the user.

Utility

This approach introduces some unorthodox structures and ideas and needs a few utilities that we have collected in a tiny pod: https://github.com/justeat/iOS.ErrorUtilities

  • ErrorBuilder – The protocol implemented by every Error builder in the app.
  • NSError+Chaining – Collection of utilities to create and query error chains, insert and extract errors.
  • NSError+HTTP – Query functions on error chains that identifies common HTTP errors.
  • NSError+Readability – Utility functions to print a readable NSError.

Conclusion

Another part of our work that hugely benefits from this approach is error logging and the post-event debugging.
Digging into the app remote or local logs is now much easier. Instead of having a spread list of errors, we now have a nice chain of errors that helps us rebuild the user journey and debug the app more efficiently using the console or our remote logging system (Kibana).
screen-shot-2018-01-03-at-13-15-53
Expanded:
screen-shot-2018-01-03-at-13-15-30

About the author

Federico Cappelli – Senior iOS Engineer at Just Eat. (Github | Github-JE)

  1. This step by step explanation has really helped me out, I was facing this issue for a long time and was unable to get help from any of the blogs, proper statements to educate ourselves is great

Comments are closed.