Add `underlyingError: Error?` property to Error

Introduction

Today, Swift's Error type gives great latitude to developers in how they choose to handle errors. That flexibility brings Swift developers a number of benefits but also defers to them a certain level of design and responsibility.

Developers find themselves in a position of deciding exactly what their Error types should and should not include.

Sometimes, that responsibility is itself deferred to a time when it's more convenient. If that time never comes, developers may find themselves in a situation where potentially bug-solving context is lost, erasing all context of a thrown error.

Or, on the other side of the coin, Swift's more dogmatic and 'obsessive/compulsive' constituents (like myself) may find themselves 'encoding their dependence tree' into their Error types in the hope that they don't lose something that they may later find useful for debugging – or perhaps to satisfy their inclination for 'type safety' or 'functional beauty'.

Finally, a developer's chosen solution may not always marry up with the solution and philosophy espoused by another developer across a module boundary.

Solution

My feeling is that the solution to this is actually remarkably simple. Include a special property on Error – 'underlyingError: Error?' – that allows Error to be used as a linked list of increasingly specific context.

Benefits

While the solution is simple, I feel the benefits are wide reaching.

For time pushed application developers this means they can simplify and focus their Error implementations on user-facing messaging and reporting, leaning on the built-in context provided by underlyingError to log more specific context with no more effort than a variable assignment.

To the more dogmatic and occasionally misguided functional adherents (again, me) we signal, 'perhaps there is no need to constrain your underlying Error types so tightly', whilst still providing all the latitude to perform rigorous control flow.

We also create a cross-boundary contract and expectation – without tight coupling of types – that can provide a developer context into the failure of a third-party module.

Future Direction

Perhaps counterintuitively, I believe that this paves a clearer path to the often raised (pun unintended) typed throws. With the new concurrency changes, the useful scope of the Result type has been reduced and no doubt a proposal for typed throws will appear once again. By including a simple way of defining a chain of thrown errors – and the inevitable utilities that will leverage it – we gently guide developers away from constraining 'all of the things'.

On that note, we also gain a type for which we can build fantastic utilities for logging. Currently, many developers lean on their analytics solutions to track unexpected errors, but I can imagine a counterpart to fatalError(_:) named something like assertNonFatalError(_:) that uses this type to produce an adorned stack trace. This could eventually be reported in Xcode alongside fatal crashes, removing a significant impediment for increasing code quality whilst still respecting privacy.

Finally, I'm sure there's some scope for syntactic sugar that will allow module developers who simply wish to re-wrap an underlying error to do so elegantly and concisely. So rather than:

do { try ... }
catch {
	let wrappingError = MyError.uhOh
	wrappingError.underlyingError = error
	throw wrappingError
}

you can:

do { try ... } annotate { throw MyError.uhOh }

And have your new error's underlying type set automatically.

Thanks for reading.

6 Likes

What would this function do?

On reflection, maybe it's named poorly.

What I imagine it would do is create some kind of 'annotated call stack', so instead of saying:

#0	0x0000000104766930 in Store.save() at /MyFile.swift

We might get something like:

#0	0x0000000104766930 in Store.save() with annotation 'Core Data save failed <MyError>' at /MyFile.swift

I have no idea if/how this is possible, but it's certainly something I'd appreciate.

Then, during testing it would raise ready for debugging, or in production it would silently log an error and continue. Perhaps these could be accessible to the system like crash reports are now and even made available in App Store Connect or Xcode when on Apple platforms.

The fatalError function already has all of this functionality. In Xcode, It prints the message parameter to the console, you can see the stack trace in the debug navigator, and it launches the lldb debugger, which you can use to do things like print the values of variables. The error message is already accessible in the crash reports in the console.

And yes, assertNonFatalError(_:) is an awful name. It implies none of the functionality that you described.

fatalError also abruptly ends execution of the application.

This would be for non-fatal errors, and would not halt execution in a production context.

The kind of Error that, in the best cases, are handled with a user-facing message and logged, and in the worst cases silently swallowed. What I'm proposing is a mechanism that would allow non-fatal but unexpected errors such as these to be as easily accessible and retrievable in a package similar to a crash report.

This would serve to give developers some context for where unexpected errors are occurring within their application without the need to ask end-users to supply logs, or to mix in with product analytics.

Like crash reports they could be exposed on Apple platforms alongside regular crash reports.

And yes, assertNonFatalError(_:) is an awful name. It implies none of the functionality that you described.

Thanks, Peter.

Perhaps generateNonFatalErrorReport would be a more descriptive name, but this isn't the central point of the proposal, just a possible future direction that would be facilitated by the convention of treating Error as a linked list.

2 Likes

There's already functionality for all of that too: raise(SIGSTOP), which pauses execution in the debugger as if you had set and hit a breakpoint, and print(error). You can wrap these calls in #if DEBUG to ensure that they only occur in debug builds. You can use the OS log APIs to log information that can then be viewed on the Console app. See Logging | Apple Developer Documentation.

Why would an end-user be using a non-production build of the program?

Furthermore, you still haven't described the signature of the generateNonFatalErrorReport function.

I should also add that Swift has the special literals #line, #function, and #file which you can use to get the location in your source code of the error at runtime.

Your suggestions are all very helpful in the test and debug phase, but do little for us in a production context.

The error reports that I would appreciate would be useful specifically for those errors which are unexpected, non fatal and arise in a production context.

Currently, we must lean on either a) logging (which requires customers to be guided through retrieving their device logs, if they contact us at all) or b) logging them through an analytics dependency.

Think of them as serving the same function as crash reports but for non fatal errors.

Crash reports are wonderful as they give us a full stack trace that can be symbolicated and take us directly to the line where the error occurred. They provide excellent context. It's currently very difficult to get that level of context for non fatal errors.

To get that level of context currently we can either a) architect an error type that allows us to collect context up the call stack and then parse it ourselves and report it via our analytics package/hope a customer is kind enough to forward the logs to us after contacting support, or b) crash the app and get a stack trace packaged up and sent to us.

In a nutshell: I'm looking for a way of getting the developer convenience of a crash report, without the customer inconvenience of a crash.

4 Likes

Well now I'm getting a better understanding of what you want. Basically what you're looking for is a way to symbolicate a call stack and a facility for remotely collecting logs produced on an end-user's device. You should've said this from the beginning; this is completely different from your original post.

Apple will probably not create the latter due to privacy concerns. You can retrieve the callstack at any point during runtime using Thread.callStackSymbols. Symbolicating the call stack is a little more complicated. This article, which is primarily intended for symbolicating crash reports, may also work for the symbols returned by Thread.callStackSymbols. Let me know if you have any success. XCTest's new XCTIssue type symbolicates the call stack for you, so this should be possible.

I don't think a single function that accepts a type conforming to Error is what you want. What if you want to log other issues that don't specifically involve an error type, such as a variable being nil?

I also don't think it makes sense to treat Error as a linked list. Are you suggesting that we add an underlyingError requirement to the Error protocol? Many error types will have a single underlyingError, but how many error types will recursively contain more underlyingErrors within each underlyingError? And even if they did, then you could get this information simply by printing the error.

This syntax just seems very bizarre to me. It too magical: It doesn't communicate what's happening behind the scenes.

Basically what you're looking for is a way to symbolicate a call stack and a facility for remotely collecting logs produced on an end-user's device. You should've said this from the beginning; this is completely different from your original post.

No, actually I'm saying: injecting a callstack into thrown Errors is one of many possibly useful future directions for enhancing Error that I would personally appreciate.

Yes, i'm familiar with Thread.callStackSymbols. It's a nice idea, but unfortunately it won't work for a couple of reasons:

  1. It generates the callstack at the point it's called, so unless you call it in the constructor of your own custom Error type, it's not going to generate much useful context.
  2. Closely related to 1, if you only create a callstack for for your own Error type, you may as well just produce a better log message. It really only becomes useful when trying to get insight into errors thrown by dependencies.

This is why I mentioned it as a 'future direction'. It's only useful if, at the point an error is thrown (indicated by the throw keyword), a callstack is generated and 'attached' to the thrown error.

It's also not much use if a module developer strips out the underlying error, which brings us onto:

Are you suggesting that we add an underlyingError requirement to the Error protocol?

This is literally what I'm suggesting – it's the title of the post. :) It would obviously have a default implementation that returns nil.

But the real point i'm getting at is that Swift, as it stands, doesn't seem to take a stance on how underlying Errors should be handled. Most of NSError has been recreated in Swift through conformance to the optional protocols LocalizedError and RecoverableError. However, NSError's underlyingErrors: [Error] has not. Why is that?

Is it a tacit indication from the Swift team that we don't need such detailed context? If so, why not? Sometimes it's really useful to traverse a stack of errors to get deeper insight into what went wrong. (Especially those non-fatal, unexpected errors that arise in production.)

You might say, 'well, if you want that level of context, you can add underlyingError: Error? to your own Error type. And, sometimes I do. But what about the dependencies I rely on? Will they include an underlying error? Or, will they infer from the lack of underlyingError: Error? property in Swift that I don't need one? Or, perhaps they will realise it might be useful and encode it within the associated value of their own custom error enum? I'll need to account for all these situations.

Or...

Just add an underlyingError requirement to the Error protocol which clears up the ambiguity.

The other option is to add WrappingError alongside LocalizedError and RecoverableError in the standard library.

protocol WrappingError: Error {
	var underlyingError: Error? { get }
}

Having no solution for getting an underlyingError: Error? just results in developers creating their own sub-par and incompatible solutions.

I'd love to hear that I'm missing something obvious.

1 Like

Thanks for writing this pitch. This is one more step for improving error handling in Swift.

This idea naturally fits the old NSError possibility – NSUnderlyingErrorKey.
The underlying error is useful in many practical tasks.

In my current project, we use our custom BaseError protocol instead of Swift.Error, which has underlyingError property. It helps a lot in debugging, testing, and troubleshooting user problems.

When error displayed to a user, he sees a chain of codes such as AP20-NE2-HT502, which means 'ApplicationError with code 20 - NetworkError with code 2 - HttpError with status code 502'. User can make a screenshot, and this chain of codes can help us to find the reason of error. Some errors are logged automatically, for example different kinds of mapping errors.

Each error from the previous application layer can be wrapped in error type of the current layer if needed. In every layer, we add additional information.

In Crashlytics we see summary user info from userInfo dicts of all underlying errors. This info can contain #FileId and #Line of each error in the chain and place in code where it was logged, the reason of json mapping failing, failed request url, index and id of object that was not found in array, unknown enum cases under unknown default, unexpected values that lead to incorrect app behaviour and much more info from surrounding context.

We also use some convenience properties, such as:

/// the deepest error in error chain
var rootError: BaseError {
    underlying?.rootError ?? self
}

/// Info dict from all error in the chain
var summaryUserInfo: [String: Any] {
  ...
}

I will be glad to know if somebody can share their workarounds and experience on this topic.

5 Likes

This problem is similar to Result type earlier, when different libraries invented their own Result types, which were incompatible with each other.

Every development team can decide to use or not underlying error, but having this option is needed in modern, large applications.

May be, it will be good to make underlyingError type generic:

protocol WrappingError: Error {
  associatedtype UnderlyingError: Error
  var underlyingError: UnderlyingError? { get }
}

What do you think?

In our project we do a similar thing: We are wrapping the errors reported by frameworks by trying to classify them according to their signature to one of our error classes (technical, ignorable etc.) and passing the underlying error as a property.

enum AppErrorClass {
  case technical
  case ignorable
  …
}

struct AppError: Error {
  let errorClass: AppErrorClass
  var underlyingError: Error?
  
  init(_ underlyingError: Error) {
    var classification: AppErrorClass
    // classification here

    self.errorClass = classification
    self.underlyingError = underlyingError
  }
}

I guess a general solution provided by the core language is a good idea.

Alamofire provides an underlyingError property for its AFError type as well. However, I'm not sure this is a general problem with a general solution but one specific to errors in a particular domain. I generally prefer to deal in real error types rather than Error (or another error protocol), so I don't know that a language-level solution is that helpful here. Despite claims in this thread, and unlike Result, I haven't seen any WrappingError-like solutions become popular in the community, or equivalent solution become prominent in popular libraries, so this doesn't seem like much of an issue. If it was to be addressed, I think the simple, non-generic, WrappingError protocol would be as far as the language should go.

The problem with using associated type here is that you'll have to specify what the type is even if underlyingError is nil.

Exactly, yes!

My understanding of this has been heavily influenced by this post on the 'typed throws' pitch:

So for these reasons I think it would probably be best to leave underlyingError: Error? as untyped, because I imagine that in most cases underlyingError: Error? will only be of use in the 'generic fall-back' situation described above – to provide a better avenue for cross-module diagnostics.

In the case where you do know the type of the underlying error that you can recover from, you'd probably be better off encoding it into your own Error type, even if that does lead to some duplication.

1 Like

In the case of AlamoFire, I don't know much about the library, but my feeling is that the reason they would include their own underlyingError: Error? property on their Error type is so that consumers of the module would have better context if something unexpected did occur either at an OS level or one of its dependencies that might guide a debugging effort.

I'd really appreciate that, as I really want better context to help channel my debugging efforts if (when?) something goes wrong, too.

I wonder how much of this is because Swift doesn't offer one, though?

It's clear from the posts above, and modules like AlamoFire, that people find themselves reaching for something like this. The fact is though, a utility like this only becomes useful with a critical mass. If it's only used by the odd module here and there it's going to be difficult to rely on as a 'generic fall-back' diagnostics utility.

I think it will take being included within the standard library to achieve that.

Thanks for clarifying this, in this perspective I agree that underlyingError: Error? is best solution for now.

2 Likes

I'm personally in favor of adding language support for error chains using some property (and maybe syntax). Other languages have similar constructs built into the language, like Python which added __cause__ to the standard Exception construct and extended the raise syntax to allow a from clause which sets the cause.

2 Likes