Python vs JavaScript Error Handling With 5 Levels

0
4K

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 LambdaAWS 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.

  1. The code becomes very procedural when you start writing many functions together. It’s very easy to follow.
  2. 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.
  3. No need for try/catch/except as a separate part of the language; you no longer need to worry about a separate code path.
  4. 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 TimeoutNetworkError, 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.

Search
Categories
Read More
Телевидение
HITTV. Прямой эфир.
HITV — музыкальный телеканал, который создаёт звезд! Мы делаем ставку на новых молодых...
By Nikolai Pokryshkin 2022-10-18 10:31:33 0 21K
Телевидение
Телеканала «Краснодар», прямой эфир
МТРК «Краснодар» - динамично развивающийся телеканал, рассказывающий о событиях и...
By Nikolai Pokryshkin 2022-10-29 13:00:12 0 15K
Научная фантастика и фэнтези
Назад в будущее. Back to the Future. (1985)
Подросток Марти с помощью машины времени, сооружённой его другом-профессором доком Брауном,...
By Nikolai Pokryshkin 2022-11-23 16:35:23 0 17K
Научная фантастика и фэнтези
Мстители: Финал. Avengers: Endgame. (2019)
Оставшиеся в живых члены команды Мстителей и их союзники должны разработать новый план, который...
By Nikolai Pokryshkin 2023-01-02 11:33:57 0 16K
Senses
Understanding Health Senses: The Connection Between Our Senses and Well-being
Our senses—sight, hearing, touch, taste, and smell—are fundamental to how we...
By Dacey Rankins 2024-12-09 13:47:52 0 723
image/svg+xml


BigMoney.VIP Powered by Hosting Pokrov