Only 2 or 3 error types are needed

June 15, 2017 0 Comments

Only 2 or 3 error types are needed

 

 

Error handling is hard, and it's made harder by a rich error hierarchy. Programs that successfully handle errors tend to have only a couple of generic handlers. Code that catches specific types of errors, in numerous locations, and tries to differentiate its handling, ends up going wrong. How can we provide both rich error information and simplified handling?

How many error types do we need? It looks like just 2, or maybe 3.

Error information vs. severity

When an error occurs, we rightfully want to collect as much information as we can about it. Was an argument out of range, could the file not be loaded, was the network down, or is there anything worth noting? This information is both valuable in debugging and giving meaningful error messages to a user. But this information is one of the reasons why we've ended up with bloated error hierarchies.

But this extended information is orthogonal to the type of the error. The handler cares only about the severity, not the details.

try { risky_stuff();
} catch( recoverable_error & re ) { restore(); log( re );
}

What does the error require of the calling code? Can we do a simple clean up or do we need to abandon further processing? By mixing the extended information in with the error type, we've made this decision hard. Not only do we have way too errors to chose from we have to deal with various wrapping classes that hide the underlying error.

In an environment with a rich exception hierarchy, like Java, C#, and even most C++, the only useful handling is to catch all exceptions!

The error mechanism is not relevant to this problem. Whether a language uses exceptions, return values, or monads, it is still subject to a proliferation of error types.

Tagging errors

There is a solution to the information problem. I first saw it in the Boost exception library for C++. Instead of creating an endless number of exception types, it uses a tagging mechanism. We can add arbitrary details to any exception without changing its type.

if( !validinput(data) ) { BOOSTTHROWEXCEPTION( recoverableerror() << tagreason( "invalid-input" ) << taginputdata( data ) )
}

This code uses a rather generic recoverableerror. It adds a tagreason, saying what went wrong, and a taginput_data, referencing the source data. We've created a detailed error without modifying the type of the exception.

There's no need to do wrapping in this approach either. Handlers can add more information directly.

try { auto data = loadfile( name ); return parse( data );
} catch( recoverableerror & re ) { re << tagfilename( name ); throw;
}

We keep the same recoverableerror type and have added a tagfilename to it. This error now has a tagreason, taginputdata and tag_filename on it. We'll print all of them to the log.

I consider tagged errors a robust solution but have not seen them outside of using Boost exceptions in C++.

Severity levels

If we don't need new types to carry details, what error types do we need?

I've argued this at length with a few colleagues. We're still in disagreement on whether it is two or three types. Yes, those are both shockingly low numbers!

One of those error types is easy to support: the critical error. These are situations that can't be handled correctly. Maybe when a severe failure has essentially broken the system, like detecting memory corruption, the inability to allocate a small object, or a VM security violation. Many C libraries call abort on such errors. We can propagate these normally, but only to get more information for debugging -- they are not recoverable!

There's no question that a critical error type exists, so the interesting question is whether we have two or just one addition error type?

The two types: stateless vs. stateful

I'm in favour of two types:


  • Stateless/unwindable errors: These are things like argument checks and pre-validation. They happen before any state in the system has been modified. The caller's state will be exactly as it was before the failed function call.

  • Stateful/recoverable errors: These happen after something has been modified already. The caller has to assume that the objects they are using, involved in the call, are not in the same state and must be cleaned up.

These give the caller clear direction in what options they have for handling the error. An unwindable error can be caught at any point and execution can proceed as though it didn't happen. A recoverable error requires the caller to cleanup before continuing.

Functional programming offers another view on these two errors. If we use only pure functions, we can't have the stateful class of errors. Unfortunately, a program can't be built from pure functions alone, but it's a thoughtful view to keep in mind.

The argument against the two type approach goes roughly as follows: programmers are just going to mess it up.

It sounds trivial, but it's a compelling argument. Handling these error types is not the problem. The problem is raising the errors. Do I expect programmers to know when it's appropriate?

More than likely a programmer will be cautious and only raise the "stateful / recoverable" type since they aren't confident they haven't modified anything. It's always safe to raise a stateful error, but incorrectly raising a stateless error can be disastrous.

Worse, a stateless error in one context may become a stateful error in another. It depends on what the previous call path has already done. Not only would we need to get the source call sites correct, but we'd also have to worry about the escalation at the right times.

There's no way this approach could work unless the compiler did most of the work.

In Leaf

But I'm writing a compiler, so maybe the idea can work. Can the compiler decide what type of error to raise, and do the required escalations? It seems like it should be possible. The compiler knows about all the memory involved, and all the assignments performed. Surely it can detect whether an error is stateless or stateful.

In practice, this is a problem. In a language that supports global memory, shared values, closures, and mutable caches, it's not easy to determine whether a function call has observable side-effects. It's not impossible though.

I think I've just eroded support for my own argument. Providing stateful and stateless errors would be onerous. Unless I want to spend the next half year on this problem alone I can't realistically implement my idea. However, since the compiler has to do it all, it's something I could introduce later and be completely backwards compatible.



Tag cloud