Introduction
I spoke at OpenSlava 2020 a few weeks back, specifically around the levels of error handling you should apply to coding. However, I wanted a written article to refer to for those who don’t want to watch the video.
The below covers the 5 levels of error handling. I call them “levels” because the idea is to start with the lowest level, learn how it works, and then level up to the next. The ideal is that you utilize level 5 error handling, pattern matching, in all types of coding you do regardless of language. If you’re operating at that level, you’ll have more predictable code. There are other types of error handling, these are just the most common I’ve seen.
The error handling skill tree is as follows:
🏎 lvl 1: ignore ’em, dynamic languages have fast iteration
⚾️ lvl 2: try/catch/throw
🏭 lvl 3: Go/Lua style, function return values, pass back up
⛓ lvl 4: pipeline style, like JavaScript Promise
🌯 lvl 5: pattern match on returned types
Level 1: Ignore Them, No Error Handling
This level is when you write code without any error handling. If they happen, you don’t care.
For example, here we access a firstName property on a Python dictionary:
name = person["firstName"]
That could either work, or fail with a runtime KeyError because the firstName doesn’t exist on person. In Python and JavaScript, this is a common thing to do; access Dictionaries and Objects with confidence and no error handling.
Here’s a more common example in JavaScript where you’re loading some JSON from an API:
const result =
await fetch(url)
.then( response => response.json() )
This example only has some error handling for an operation that is notorious for having errors: making network calls. While the author has mixed the async/await syntax with the Promise.then syntax, and ensures the response.json(), if it fails, is handled, they used async/await, so the code will throw an uncaught exception anyway since there is no wrapping try/catch. Perhaps the author was in a hurry, doesn’t get how Promises work in JavaScript, or just copied and pasted code to test something.
There are a variety of valid reasons you may intentionally want to do Level 1 style of “not caring”.
Playing With Ideas & Domain Modeling
The first is when you’re playing with ideas to learn your domain. In programming, a domain is “the problem area you’re trying to solve”. This could be as small as converting temperatures from Fahrenheit to Celsius, as large as building an online furniture purchasing & shipping system, or you may not even know the scope yet. In those situations, whether you’ve given thought ahead of time to architecture, or perhaps you just think faster slinging code ideas around, you’re often modeling pieces of the domain various ways.
Think “playing with crayons” or “writing words so you don’t get writers block and not actually start writing the book”. Once you get a feel for how things work, and see it in code, you’ll start to potentially see the domain in your head using your mostly working code as a guide. The errors aren’t important because this code isn’t committed yet, or they’re just edge cases you don’t care about yet.
Supervisor Pattern
The second way is you know you’re running in a system that automatically handles them for you. Python and JavaScript have various ways using try/except | try/catch to handle synchronous errors, and various global exception capabilities. However, if you’re running in an architecture that automatically catches these, then if the code is simple enough, you may not care. Examples include AWS Lambda, AWS Step Functions, Docker containers running on ECS or EKS. Or perhaps you’re coding Elixir/Erlang which has a philosophy of “let it crash“; Akka has this philosophy too. All of these services and architectures encourage your code to crash and they’ll handle it, not you. This greatly simplifies your architecture, and how much code you need to write depending on your language.
Learning New Things
Another reason is you’re learning. For example, let’s say I want to learn how to do pattern matching in Python, and I don’t want to use a library. I’ll read this blog post, and attempt the examples the author lays out. The errors may help or not; the point is my goal is to learn a technique, I’m not interested in keeping the code or error handling.
Level 1 is best for when you’re playing with ideas and do not care if things crash.
Level 2: try/except/raise or try/except/throw
Level 2 is when you are manually catching synchronous errors using try/except in Python and try/catch in JavaScript. I’m lumping various async and global exception handling into here as well. The goal here is to catch known errors and either log the ones you can’t recover from, or take a different code path for the ones you can such as default values or retrying a failed action as 2 examples.
How Thorough Do You Get?
Python and JavaScript are dynamic languages, so just about every part of the language can crash. Languages like Java, for example, have keywords like throwable which makes the compiler say “Hey, you should put a try/catch here”. Since Java has types, despite being unsound, there are still many cases where you don’t have to worry about crashes because of those types. This means, there aren’t really any rules or good guidance for how thorough you should get using error handling in your code.
For those who don’t use any, some may question why not for the obvious cases. This includes anything I/O related such as our http rest call example above, or reading files. The general consensus from many dynamic language practitioners appears to be if you spelled things right, then the only way it can fail is from outside forces giving you bad data.
try:
result = request(url)['Body'].json()
except Exception as e:
print("failed to load JSON:", e)
For those who use it everywhere, others will question what are the performance costs and readability costs of the code. In our firstName accessing a Python dictionary above, if you’re not using lenses then you’re only recourse is to either check for the existence of keys:
if "firstName" in person:
return person["firstName"]
return None
… however, now we have Python functions later expecting a String getting None
instead, and throwing exceptions. More on that later.
In JavaScript, same story using optional chaining looking for nested properties:
return person.address?.street
While this makes accessing properties safer, and no runtime exceptions are thrown, how you utilize that data downstream may result in runtime exceptions if something gets an undefined
when it wasn’t expecting it.
Programmers have different coding styles and beliefs, and so how thorough they get in this level is really dependent on that style and the programming language.
Create Errors or Not?
Level 2 includes embracing those errors as types and the mechanisms that use them. For types of code where many things can go wrong, the way you implement that in Level 2 is creating different Errors for the different failures… maybe. Some people using Level 2 think you should handle errors but not create them. Others say embrace what the language provides, then checking the error type at runtime. For Python and JavaScript, that’s extending some Error base class.
For example, if you wanted to abstract all the possible things that could go wrong with the JavaScript AJAX function fetch
, then you’d create 5 classes. For brevity, we won’t put details you’d want about the error in the class examples below, but it’s assumed they’d have that information as public class properties:
class BadUrlError extends Error {}
class Timeout extends Error {}
class NetworkError extends Error {}
class BadStatus extends Error {}
class GoodStatus extends Error {}
Then when you do a fetch call, you can more clearly know what went wrong, and possibly react to it if you are able such as log the problem error or retry:
try {
const person = await loadPerson("/person/${id}")
} catch (error) {
if(error instanceof BadUrlError) {
console.log("Check '/person/${id}' as the URL because something went wrong there.")
} else if(error instanceof Timeout || error instanceof NetworkError || error instanceof BadStatus) {
retry( { func: loadPerson, retryAttempt: 2, maxAttempts: 3 })
} else {
console.log("Unknown error:", error)
throw error
}
In your fetch wrapper class/function, you specifically will be throw new BadUrlError(...)
based on interpreting the various things that can go wrong with fetch. For any you miss, the caller is assumed to just log and re-throw it.
In Python, this Java style of exception handling is prevalent if the author either comes from that language, or just follows a strict Object Oriented Programming style:
try:
person = load_person(f'/person/{id}')
except BadUrlError:
print(f'Check /person/{id} as the URL because something went wrong there.')
except Timeout:
except NetworkError:
except BadStatus:
retry(func=load_person, retry_attempt=2, max_attempts=3)
except Exception as e:
raise e
Level 3: Errors as Return Values
Lua and Go have approached error handling differently. Instead of treating errors as a separate mechanism of functions and classes the function lets you know if it worked or not. This means that functions need to tell you 3 things: if it worked or not, if it did what is the return value, and if it didn’t what is the error. At a bare minimum, you’d need to return 2 things from a function instead of 1 thing.
And that’s what Lua and Go do; they allow you to return multiple values from functions.
While Lua doesn’t enforce this code style, it’s a normal convention in Golang. Here’s how Go would handle reading a file:
f, err := os.Open("filename.ext")
if err != nil {
log.Fatal(err)
}
Changing our JavaScript HTTP example to adopt this style by having loadPerson
return an Object
with either the error or the person, but never both:
const { error, person } = await loadPerson("/person/${id}")
if(error) {
return { error }
}
Python is a bit easier in that you can return a Tuple and the destructuring of the arguments converts them to variables. The load_person
function would return (None, person_json)
for success and (the_error, None)
for failure.
error, person = load_person(f'/person/{id}')
if error:
return (error, None)
This has some pro’s and con’s. Let’s hit the pro’s first.
- The code becomes very procedural when you start writing many functions together. It’s very easy to follow.
- Every function can return many possible errors of functions it is using and they all come out the same way; the way you work with data and errors is the same.
- No need for try/catch/except as a separate part of the language; you no longer need to worry about a separate code path.
- You can still opt out and ignore errors like Level 1 if you wish just play with code, or the errors don’t matter, but it won’t break the code like Level 1 does when you ignore them.
Cons? This style, if you handle all errors, can get verbose very quickly. Despite using the succinct Python language, it still can drag on:
error, string = load_person_string(file_path)
if error:
return (error, None)
error, people_list = parse_people_string(string)
if error:
return (error, None)
error, names = filter_and_format_names(people_list)
if error:
return (error, None)
return (None, names)
One last point is not all functions need to return success or failures. If you know your function can’t fail, has a low likelihood it will, or isn’t doing any I/O, then you can just return your value. Examples include getting today’s date, or what OS you’re running on. However, given Python and JavaScript are dynamic, you have no guarantee’s at runtime. Even using mypy or TypeScript, both are unsound typed languages, so while it significantly increases your chances, you still can’t be sure. Sometimes a hybrid approach is best. For example, Boto3, the AWS Python SDK has an extremely consistent behavior with almost all methods of “if it works, it returns the data; if it doesn’t, it raises an Exception”. This means you can adopt Level 3 VERY WELL with the Python AWS SDK because of this consistent behavior.
Level 4: Pipelines
Thankfully, that verbosity & repetition problem has already been solved in Functional Languages using pipelines, also called Railway Oriented Programming. Pipelines are taking that concept of functions letting you know if they worked or not, and wiring them together into a single function. It’s much like how Lua and Golang work, except less verbosity. The benefits, beyond less code, is you only have to define error handling in 1 place. Like Level 3, you can opt out if you wish by simply not defining a catch
.
JavaScript Asynchronous
We’ll hit JavaScript Promises first as this is the most common way to do this pipeline style of error handling.
fetch(someURL)
.then( response => response.json() )
.then( filterHumans )
.then( extractNames )
.then( names => names.map( name => name.toUpperCase() ) )
.catch( error => console.log("One of the numerous functions above broke:", error) )
To really appreciate the above, you should compare that to Golang style, and you’ll recognize how much simpler it is to read and how much less code it is to write. If you are just playing with ideas, you can delete the catch
at the end if you don’t care about errors. Whether fetch
fails with it’s 5 possible errors, or response.json
fails because it’s not parseable JSON, or perhaps the response
is messed up, or any of the rest of the functions… whatever, they’ll all stop immediately when they have an error and jump right to the catch part. Otherwise the result of one function is automatically put into the next. Lastly, for JavaScript, it doesn’t matter if the function is synchronous or asynchronous; it just works.
Python Pipelines
Python pipelines are a bit different. We’ll ignore async/await & thread pooling in Python for now and assume the nice part of Python is that sync and async mostly feel and look the same in code. This causes a pro of Python in that you can use synchronous style functions that work for both sync and async style code. We’ll cover a few.
PyDash Chain
Let’s rewrite the JavaScript example above using PyDash’s chain:
chain(request(some_url))
.thru(lambda res: res.json())
.filter( lambda person: person.type == 'human' )
.map( lambda human: human['name'] )
.map( lambda name: name.upper() )
.value()
The issue here is that you still have to wrap this whole thing in try/except. A better strategy is to make all functions pure functions, and simply return a Result like in Level 3, but PyDash doesn’t make any assumptions about your return types so that’s all on you and not fun.
Returns @safe & Flow
While PyDash does allow creating these pipelines, they don’t work like JavaScript where we can take a value or error and know if we need to stop and call our catch, or continue our pipeline with the latest value. This is where the returns library comes in and provides you with a proper Result
type first, then provides functions that know how to compose pipelines of functions that return results.
Instead of a Level 3 function in Python returning error, data
, it instead returns a Result. Think of it like a base class that has 2 sub-classes: Success
for the data
and Failure
for the error
. While the function returns a single value, that’s not the point; the real fun is now you can compose them together into a single function:
flow(
safe_parse_json,
bind(lambda person: person.type == 'human'),
lambda human: get_or('no name', 'name', human),
lambda name: name.upper()
)
That’ll give you a Result
at the end; either it’s successful, a Success
type, and you’re data is inside, or it’s a Failure
and the error is inside. How you unwrap that is up to you. You can call unwrap
and it’ll give you the value or throw an exception. Or you can test if it’s successful; lots of options here. Perhaps you’re running in a Lambda or Docker container and don’t care if you have errors so just use unwrap
at the end. Or perhaps you are using Level 3 because you’re working with Go developers forced to use Python, so convert it:
result = my_flow(...)
if is_successful(result) == False:
return (result.failure(), None)
return (None, result.unwrap())
De Facto Pipes
This is such a common pattern, many languages have this functionality built in, and many also abstract away whether it’s synchronous or not. Examples include F#, ReScript, and Elm. Here’s a JavaScript example using the Babel plugin, and note it doesn’t matter if it’s async or sync, just like a Promise
return value:
someURL
|> fetch
|> response => response.json()
|> filterHumans
|> extractNames
|> names => names.map( name => name.toUpperCase() )
Notes on Types
Just a note on types here. While JavaScript and Python aren’t known for types, recently many JavaScript developers have embraced TypeScript and a few Python developers have moved beyond the built in type hints to use mypy. For building these pipelines, TypeScript 4.1 has variadic tuples which can help, whereas returns does its best to support 7 to 21 pipes of strong typing. This is because these languages weren’t built with Railway Oriented Programming in mind, if you’re wondering why the friction.
Level 5: Pattern Matching
The last level for this article, pattern matching is like a more powerful switch statement in 3 ways. First, switch statements match on a value where most pattern matching allows you to match on many types of values, including strong types. Second, switch statements don’t always have to return a value, and nor does pattern matching, but it’s more common that you do. Third, pattern matching has an implied catch all like default that is strong type enforced, similar to TypeScript’s strict mode for switch statements, ensuring you can’t miss a case
.
JavaScript Pattern Matching
Here’s a basic function in JavaScript using Folktale to validate a name.
const legitName = name => {
if(typeof name !== 'string') {
return Failure(["Name is not a String."])
}
if(name.length < 1 && name !== " ") {
return Failure(["Name is not long enough, it needs to be at least 1 character and not an empty string."])
}
return Success(name)
}
We can then pattern match on the result:
legitName("Jesse")
.matchWith({
Failure: ({ value }) => console.log("Failed to validate:", value),
Success: ({ value }) => console.log(value + " is a legit name.")
})
At the time of this writing, the JavaScript proposal is at Stage 1, but if you’re adventurous there is a Babel plugin or the Sparkler library if Folktale doesn’t do it for you.
If you were to write that as a switch statement, it may look like:
switch(legitName(value)) {
case "not legit":
console.log("Failed to validate:", getWhyInvalid(value))
break
case "legit":
console.log(value + " is a legit name.")
break
default:
console.log("Never get here.")
}
A few things to note here. First, in pattern matching, you’re typically using some type of Union type. Whereas Dictionaries in Python can have any number of properties added, or Objects in JavaScript the same, Unions are fixed. Our Validation
type above only has 2: Success
or Failure
. This means we only have to pattern match on 2. If you’re using a type system, then it knows for a fact there are only 2. If you do 3, it’ll yell at you. If you do just Success
, it’ll yell at you that you’re missing Failure
.
Compare that to the switch statement which has no idea. You technically don’t need the default
, but unless what you’re switching on is a Union, the compiler doesn’t know that so you have to put it there even though it’ll never go. How dumb.
Python Pattern Matching via Pampy
Also, both examples above don’t return a value, but this is actually a common functionality of pattern matching. Let’s implement our HTTP REST call as a pattern match using Python via the Pampy library, and we’ll return a Python Union, specifically a Result from returns which either it worked and we put the data in a Success
or it failed and we put the reason why in a Failure
:
result = match(load_person(f'/person/{id}'),
Json, lambda json_data: Success(json_data),
BadUrl, lambda: Failure(f"Something is wrong with the url '/person/{id}'"),
Timeout, lambda: retry(func=load_person, retry_attempt=2, max_attempts=3),
NetworkError, lambda: retry(func=load_person, retry_attempt=2, max_attempts=3),
BadStatus, lambda: retry(func=load_person, retry_attempt=2, max_attempts=3)
)
For our first attempt, if we get Json
, cool, everything worked and our result
will have our JSON data we wanted.
If we have a BadUrl
, however, we’re in trouble because that means something is wrong with our code in how we wrote the URL, or perhaps we read it incorrectly from an environment variable we thought was there but isn’t. There is nothing we can do here but fix our code, and make it more resilient by possibly providing a default value with some URL validation beforehand.
However, we’re violating DRY (Don’t Repeat Yourself) here a bit by Timeout
, NetworkError
, and BadStatus
all doing the same thing of attempting a retry. Since you typically pattern match on Unions, you know ahead of time how many possible states there are (usually; some languages allow you to pattern match on OTHER things that have infinite spaces. For the sake of this article, we’re just focusing on errors). So we can use that catch all which is an underscore (_). Let’s rewrite it:
result = match(load_person(f'/person/{id}'),
Json, lambda json_data: Success(json_data),
BadUrl, lambda: Failure(f"Something is wrong with the url '/person/{id}'"),
_, lambda: retry(func=load_person, retry_attempt=2, max_attempts=3)
)
Much better. Also note compared to a switch statement, you KNOW what the _ represents, and often have a compiler to help you whereas a switch won’t always know what is in the default. Our example above provides the data, a failure, and MAYBE a success if the retry is successful, else it’ll eventually return an error after exhausting its retries.
If you want something more Pythonic than Pampy, you can try pattern matching in Python using dataclasses.
Pattern Matching is More than Just Error Handling
One subtle thing to not here is pattern matching is often just a language feature in more functional languages. As such, you can use it in every error handling level. For example, here is the above in Level 1 style of “I don’t care, just playing with ideas”:
result = match(load_person(f'/person/{id}'),
Json, lambda json_data: Success(json_data),
_, lambda: Success([]) # TODO: just empty Array for now, not sure why my parsing is failing, will fix later
)
Again, if you’re working with Go Developers forced to use Python, you can pattern match back to Level 3:
result = match(load_person(f'/person/{id}'),
Json, lambda json_data: (None, json_data),
BadUrl, lambda: (Exception(f"Something is wrong with the url '/person/{id}'"), None),
_, lambda: retry(func=load_person, retry_attempt=2, max_attempts=3)
)
For Level 4, many pipelines just assume whatever you return from the pattern match goes back into the pipeline. For example, our people parser above, if the data is from a technical debt filled back-end or database that has bad data, we can help compensate. We do this by pattern matching on the extract_names
to ensure we just provide a default vs. breaking the whole pipeline. If someone’s name was blank because you can’t have null values in DynamoDB, that shouldn’t stop everything. Finally, since we know all the possible outcomes, we’ll pattern match in the catch
to ensure the function NEVER fails, and instead, let the consumer pattern match on the known results. For those who don’t understand promises and just use async/await syntax without try/catch, this allows them to do so without hurting the codebase. First, we’ll build a small function pattern match on the possibility we get a human Object with no name.
const getNameElseDefault = human =>
getNameMaybe(human).matchWith({
Nothing: () => "no name found",
Just: ({ value }) => value
})
We’ll then wire her into our existing JavaScript pipeline below: (assume we’ve modified response.json()
to throw a custom Error like in Level 2):
const getPeople = () =>
Promise.resolve(someURL)
.then( fetch )
.then( response => response.json() )
.then( filterHumans )
.then(
humans =>
humans.map(getNameElseDefault)
)
.then( names => names.map( name => name.toUpperCase() ) )
.then( uppercaseNames => Json(uppercaseNames) )
.catch(
error =>
error => error.matchWith({
FailedToParseJSON: parseError => Promise.resolve(parseError),
BadUrl: badurlError => Promise.resolve(badurlError),
_: otherError => Promise.resolve(otherError)
})
)
Now, whoever is consuming this function can just pattern match on 2 values:
const result = await getPeople()
result.matchWith({
Json: ({ uppercaseNames }) => console.log("Got our people names:", uppercaseNames),
_ => error => console.log("Something broke:", error)
})
Pro’s and Con’s of Pattern Matching
If you’re not using types, the advantages are similar to Level 3 in that you start assuming all functions never fail and instead just let you know if what they were attempting worked or not. When things get more complicated than just 2 possible results like “Success” or “Failure” like it does in HTTP responses, you can then create your own and match on those. Just because something has 5 possible outcomes, you can use the catch all _
when you need to lump all errors into one or just don’t care. There is no need to do manual error handling such as try/except/catch.
If you’re using types, you can ensure you’ve handled all possible matches, so you never miss a function return type. Even with types, you can still lump all of them to _
if you’re just playing with ideas.
However, many languages don’t support this functionality natively. It’s slowly being bolted on to Python and JavaScript. Using the libraries and techniques above may be strange for those coming from traditional imperative or Object Oriented Python/JavaScript. Level 3 is a hard enough swallow to say to someone “You know how we have raised/thrown exceptions? What if you didn’t have that anymore.” Now you’re saying “all functions that might fail, we return an Object and you can have to determine how to handle it”. That’s a lot for many developers to take in, especially when most traditional programming literature cites “Yeah, it’s assumed you just use try/catch”.
Finally, without types, you can usually do ok using Maybe
and Result
as it’s relatively easy to memorize over time their 2 sub-types, like Just/Nothing and Success/Failure. But when you create custom ones, or start nesting them into composed functions and have no idea what’s coming out, it can be tough. Those who already are comfortable with dynamic languages are typically fine with printing the output to learn what those types are vs. using a typed language to have the compiler help you.
Conclusions
I’ve explained the 5 levels of error handling, specifically for dynamic languages:
- You ignore them
- You handle them using try/except/catch and raise/throw to varying degrees
- You adopt Lua/Golang’s method of returning multiple values indicate success or failure
- You create pipelines and handle the error in 1 place vs. many like Level 3
- You match results that a function can return such as success or failure, or more nuanced results such as HTTP, using functions instead of a exception matching like in Level 2
While it’s important and valuable to know each level, and each does have it’s uses, you’ll want to use Level 4 and 5 for production code. You should reserve the right to ignore errors and live in Level 1 when you’re learning how to solve your problem. However, when you’re ready to start coding the project for real, aim for Level 4 and 5. These ensure the least surprise runtime exceptions and less overhead in unit testing of functionality.
For dynamic languages, a lot of the onus is on you the developer to memorize the types and shapes of Dictionaries/Objects. Level 1 and 2 are hard because sometimes you just get an Exception
or Error
, and other types error types are documented. They ARE helpful for logging since many API’s and SDK’s are built this way to help you figure out what broke inside their abstractions. You’ll find over time, though, that beyond logging, you always end up at “she either worked or she didn’t” and you’ll start to abandon your log exception handling stacks. You’ll never reach consensus with your team or yourself about how much try/except is enough. You’ll struggle to see return on investment in creating custom Exception classes.
Once you get to Level 3, even if not using Go, you’ll like the less code involved, and freedom to only return errors on functions that you deem risky. Yet without a compiler, you’ll have the same problems as Level 2 and never really know what is enough error handling.
There are various pipeline options for Python, and even JavaScript has alternatives to Promise
like RxJS. You’ll find, though, that the concept of an Error class isn’t really helpful if you can’t compare it to others easily, and so Level 5 pattern matching goes much better with pipeline workflows, both in reducing the boilerplate code required in Level 3 error checking, and being able to just inject anywhere in the pipeline you wish. Much pattern matching documentation will cover the swath of things you can match on, like simple numbers and lists, but for error handling, it’s assumed some kind of dataclass or type. While pipelines like JavaScript Promises spit out data or raise an Exception, it’s better if you treat ’em like Level 3 functions that return success/failure values, and go from there.