25. Validation, Exceptions, and Error Handling#

aka Developing Robust Code

So far, in our code, we have followed the “happy” path where everything works, and we assume our input values are correct. If something went wrong, we either re-ran our code (e.g., the user entered an incorrect input value) or made a code fix and re-ran the code.

Software needs to be reliable and robust. That means that it needs to prevent errors when possible, detect situations when errors do occur, and then recover from errors when possible.

Throughout these notebooks, we have seen many errors and their associated messages, sometimes intentionally, sometimes accidentally, and sometimes just incorrect logic (semantic errors).

Now we need to make a few decisions:

  1. When do we want to validate input?

  2. How do we want to validate input?

  3. How do we want to prevent errors?

  4. How do we want to react and recover from errors?

No obvious, fixed solutions exist for these questions. As with many things with system design, the answers will depend upon the context.

For scripts that we write for ourselves, ignoring errors may be acceptable in some circumstances. However, we must ensure our code performs robustly for code or results that others use.

Input validation depends upon the source, the potential harm of using the data unchecked, and the ability of the exception handling to detect and handle the message. Nevertheless, input validation, while often annoying and time-consuming, is the best way to produce robust code with minimal security flaws.

Our programs should validate any data received from external sources. Validation includes checking -

  • that data falls within allowable(expected) ranges

  • that the system can handle numeric values. While Python does not limit integers’ size, the language does for floats. If you are using a database system to persist data, most likely, that system will have more stringent limits.

  • for strings, we need to look at their length as well as any patterns that data might represent (e.g., Visa and Master credit cards have a format of NNNN NNNN NNNN NNNN where N is a digit from 0 to 9.)

  • malformed input designed to perform an injection attack when the data is used without proper validation or escaping see html.escape().

You should consider checking the values of input parameters to functions and methods.

Software Security Flaws
The Open Web Application Security Project has run an awareness project over the past 20 years to identify the top security issues facing developers in web applications. Examining these security categories over time, many are directly related to the lack of input validation or sanitization (the process of removing illegal characters from input or replacing potential dangerous character sequences with safe ones): Buffer Overflow, Cross-Site Request Forgery (CSRF), Cross Site Scripting (XSS), Injection, Injection Flaws, Server-Side Request Forgery, Unvalidated Input, and Unvalidated Redirects. The project has now combined most of these issues into the "injection" category.

Python contains two potentially dangerous built-in functions: eval() and exec(). eval() evaluates a string assuming it is an expression. Remember - a function call is an expression!. exec() executes the contents of the string as if it represents one or more Python statements. While legitimate use cases exist for both functions, developers must use extreme care to ensure that any string values passed to these functions are safe to execute.

As an example, create a code cell and execute the following:

eval('exec("import os; print(os.listdir(\'.\'))")')
    
Listing a directory's contents seems innocuous, but the results could give valuable information to an attacker. And if someone could execute that code, they could execute far more malicious code.

As we detect errors, we also need to determine who is responsible for recovering from the error as well as how to react and recover. Are these activities performed within the current routine/function? Or passed back up the caller stack? How is the user informed? How does this differ among programs deployed as command-line tools, local GUIs, and web applications?

A phrase long used in computer science has been “garbage in, garbage out”. Input quality directly influences output quality (results). Therefore, use unvalidated data at your own peril.

25.1. Revisiting User Input#

In this below code snippet from the Iteration Notebook, the user enters grades until they are complete, which they signify by entering a negative number. We had already added some error checking to see if the user entered at least one grade before calculating the average - this prevented a division by zero. However, what occurs if they enter a value that’s not an integer? Let’s try -

 1total = 0
 2num_entries = 0
 3
 4while True:
 5    grade = int(input("Enter a grade: "))
 6    if grade < 0:
 7        break
 8    total += grade
 9    num_entries += 1
10
11if num_entries > 0:
12    print("Average:",total/num_entries)
13else:
14    print("no grades entered")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 8 line 5
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X10sZmlsZQ%3D%3D?line=1'>2</a> num_entries = 0
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X10sZmlsZQ%3D%3D?line=3'>4</a> while True:
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X10sZmlsZQ%3D%3D?line=4'>5</a>     grade = int(input("Enter a grade: "))
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X10sZmlsZQ%3D%3D?line=5'>6</a>     if grade < 0:
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X10sZmlsZQ%3D%3D?line=6'>7</a>         break

ValueError: invalid literal for int() with base 10: 'B'

Whether you entered a string literal or a float literal, you should have received a “ValueError” that occurs when Python attempts to convert the string return value from input into an integer.

Another error that could have occurred if we ran this script from a shell session is an “EOFError” if the input stream was closed (e.g., the user typed ctrl+d). Within Jupyter, it is not possible to replicate this issue - the environment does not provide any mechanism to close the input stream.

Several possibilities exist to validate that a string does represent an integer.

One possibility is to create some custom logic to ensure that each character in the string is a valid digit between 0 and 9. This option needs a check that the leading character could be a negative sign. With this approach, we would want to create a function such that other parts of our code (or even other programs) could reuse this logic.

When possible, though, we should try to reuse code. Are there any methods in Python’s string class? Looking at that documentation, several possibilities may exist: isdecimal(), isdigit(), and isnumerical().

isdecimal() returns true as long as the string is composed of any character in the ‘Unicode General Category ND’ .

1test_strings = ['65536','00123','-2','0.124','life42','42life','\u00BD','\u1C43','\u2460']
2for s in test_strings:
3    print("{:>10}".format(s), s.isdecimal())
     65536 True
     00123 True
        -2 False
     0.124 False
    life42 False
    42life False
         ½ False
         ᱃ True
         ① False

This method does well, although it cannot handle a negative number. We could still deal with that, though, by stripping the leading -.

isdigit() still does not handle negative numbers. It also accepts numbers that are not base 10, such as ①.

1for s in test_strings:
2    print("{:>10}".format(s), s.isdigit())
     65536 True
     00123 True
        -2 False
     0.124 False
    life42 False
    42life False
         ½ False
         ᱃ True
         ① True
1int('\u2460')
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 15 line 1
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X20sZmlsZQ%3D%3D?line=0'>1</a> int('\u2460')

ValueError: invalid literal for int() with base 10: '①'

isnumeric() is even further away from the right solution. The function accepts anything that can represent a number - including fractions. And, no, it does not handle negative numbers.

1for s in test_strings:
2    print("{:>10}".format(s), s.isnumeric())
     65536 True
     00123 True
        -2 False
     0.124 False
    life42 False
    42life False
         ½ True
         ᱃ True
         ① True

Another possibility covered in a later notebook is to use a regular expression. Through a powerful notational grammar, regular expressions can find simple and complex text patterns.

For this situation (to test if a string represents an integer), we will use the regular expression ^[-+]?[\d]+$. The expression works as follows:

  • ^ means to match the start of the string

  • [+-] is a character class consisting of either the plus + sign or the minus - sign.

  • ? makes that previous character optional

  • [\d] matches any numeric decimal character (similar to isdecimal()). This match includes characters from 0 to 9 and the equivalent characters in other language writing systems.

  • + means that previous character (or any member of the character class [0-9]) must be present one or more times. i.e., at least once

  • $ means to match the end of the string

From this regular expression, we can see that when we convert a string to an integer, we can have an arbitrary number of leading zeros. However, as demonstrated earlier, the int() function can parse leading zeros while the Python interpreter cannot parse integer literals (values embedded in the program) with leading zeros.

1import re
2for s in test_strings:
3    print("{:>10}".format(s), bool(re.match(r"^[-+]?[\d]+$",s)))
     65536 True
     00123 True
        -2 True
     0.124 False
    life42 False
    42life False
         ½ False
         ᱃ True
         ① False

The final solution to examine goes back to that pesky ValueError. Fortunately, Python allows us to capture and handle these types of errors.

25.2. Exceptions#

An exception is an error that occurs as a program executes, causing the normal execution sequence to stop processing and for control to pass to the nearest block designated to handle that type of error. If no such handler is present, the Python interpreter will print a traceback (stack trace) and stop the program. The fundamental concept relies upon decoupling error detection from error handling, guaranteeing that any identified errors cannot be disregarded.

Another way to define an exception is an error that prevents a program from continuing normal execution.

25.2.1. Handling Errors#

Python provides the try except statement to handle errors. The try block contains code in which an error may occur. The except block provides the necessary error handling. However, you may still need more code outside the except block to recover from the error appropriately.

1s = "hello"
2i = int(s)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 23 line 2
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X31sZmlsZQ%3D%3D?line=0'>1</a> s = "hello"
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X31sZmlsZQ%3D%3D?line=1'>2</a> i = int(s)

ValueError: invalid literal for int() with base 10: 'hello'
1try:
2    s = "hello"
3    i = int(s)
4except:
5    print("'{:s}' is not a valid number.".format(s))
'hello' is not a valid number.

With no other details on the except line, that except block is a catch-all for any error type.

For the grade average example, we need to determine what type of action to take when the user enters a string that is not an integer. Ideally, we want the user to be able to recover from any inadvertent mistakes. We also need a mechanism to allow the user to indicate they have finished entering data - typically accomplished through a sentinel value. Our initial sentinel value was any negative number, but was that the most appropriate choice?

Rather than starting from our existing, let’s revisit the pseudocode for this process:


    set running total to 0
    set number of grade entries to 0
    while the user has more grade entries:
        read next grade
        add grade to running total
        increment number of entries by 1
    if there's at least one grade entry
        compute and display average
    otherwise
        state no grades entered

We have a straightforward conversion for most of the pseudocode except for the loop. Let’s expand that -


    ...
    while true:
        read next grade
        if sentinel entered, exit loop
        if valid grade (an integer)
            add grade to running total
            increment number of entries by 1
        else 
            display error message
    ...

Now, convert the pseudocode to Python code. Since we are changing our logic, rather than relying upon a negative integer for the sentinel, let’s use the string “q”.

 1total = 0
 2num_entries = 0
 3
 4while True:
 5    grade = input("Enter a grade('q' to quit): ")
 6    if grade == 'q':
 7        break
 8    try:
 9        grade = int(grade)
10        total += grade
11        num_entries += 1
12    except:
13        print("Invalid grade entered: ",grade)
14
15if num_entries > 0:
16    print("Average:",total/num_entries)
17else:
18    print("no grades entered")
no grades entered

25.2.2. Handle by Type#

The except clause with just the keyword itself catches all errors by default. However, in certain circumstances, our code may only want to handle a specific error, making it the caller’s responsibility to handle (or not!) any other errors that may exist.

For example, what a file contained the grades, one entry per line, and we had this code to compute averages:

with open("test.txt") as f:
    total = 0
    num_entries = 0
    for line in f:
        total += int(line)
        num_entries += 1
    print(total/num_entries)

What errors may occur?

  • The file does not exist

  • The file uses a different encoding

  • The file contains data other than integers

  • The file is empty

Now, we need to determine how to handle these different errors.

Python provides the capability to handle a specific error by specifying that error type after the except. View Python’s Built-in Exceptions

As you can see below, Python allows for multiple except blocks for each try statement. Once an except block executes for a try statement, the interpreter skips checking the remaining except blocks. Because of this logic, it is also necessary to list except blocks in order of most specific to the most general.

Python allows us to specify a variable name after the exception. By referencing the exception object, we can query the exception’s state (it is just an object) to access additional details.

The following code block contains several errors. While the comments provide the corrections, you should run the code first. Then fix the associated error. Repeat until the average is printed. Finally, remove the inner most try/except handling to see how the code reacts. What occurred?

 1filename = "data/grade.txt"       # Correct filename is data/grades.txt
 2try:
 3    with open(file_name) as f:    # Variable name has not been defined
 4        total = 0
 5        num_entries = 0
 6        line_count = 0
 7        for line in f:
 8            line_count += 1
 9            try:
10                total += int(line)
11                num_entries += 1
12            except ValueError:
13                print("Bad integer value on line {:d}: {:s}".format(line_count,line))
14        print("Average: {:.2f}".format(total/num_entries))
15except FileNotFoundError as file_error:
16    # show additional attributes for "FileNotFoundError"
17    print(file_error.filename)
18    print(file_error.errno)
19    print(file_error.strerror)
20    print("Unable to find:", filename)
21except Exception as err:
22    print("Unknown error:", err)    # this prints the excpetion's default error message
23    print("Error Type:", type(err))
Unknown error: name 'file_name' is not defined
Error Type: <class 'NameError'>

Programmers can specify multiple exceptions with the same except block:

filename = "data/grade.txt"       # Correct filename is data/grades.txt
try:
    ...
except (FileNotFoundError, IOError) as os_error:
    # Handle OS related errors in this block
    ...
except Exception as err:
    # Handle other errors in this block
    ...

25.2.3. finally clause#

Additionally, the try/except statement can have an optional finally clause which executes after the try block and the except block(s). I.e., the finally clause executes as the last item of a try/except statement. The purpose is to perform any necessary cleanup operations. Code in the finally clause always executes.

try:
    statement
    statement
    ...
except ExceptionName:
    statement
    statement
    ...
finally:
    statement
    statement
    ...

25.2.4. Raising Exceptions#

With Python, we can force a specified exception to occur through the raise statement:

1raise NameError("code initiated exception")
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 33 line 1
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X44sZmlsZQ%3D%3D?line=0'>1</a> raise NameError("code initiated exception")

NameError: code initiated exception

We can catch these exception ourselves as well as re-raise them (any type of an exception can be re-raised). By re-raising an exception, we can perform some initial error handling and then delegate the rest of the error handling to the caller.

1try:
2    raise NameError("code initiated exception")
3except NameError as ne:
4    print("Our raised exception:",ne)
5    raise ne
Our raised exception: code initiated exception
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 35 line 5
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X46sZmlsZQ%3D%3D?line=2'>3</a> except NameError as ne:
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X46sZmlsZQ%3D%3D?line=3'>4</a>     print("Our raised exception:",ne)
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X46sZmlsZQ%3D%3D?line=4'>5</a>     raise ne

/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 35 line 2
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X46sZmlsZQ%3D%3D?line=0'>1</a> try:
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X46sZmlsZQ%3D%3D?line=1'>2</a>     raise NameError("code initiated exception")
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X46sZmlsZQ%3D%3D?line=2'>3</a> except NameError as ne:
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#X46sZmlsZQ%3D%3D?line=3'>4</a>     print("Our raised exception:",ne)

NameError: code initiated exception

25.2.5. Creating Exceptions#

While Python contains many built-in exception types that we can utilize, we can also define new exception types more specific to the situations that may arise in our programs.

The built-in exceptions are classes within Python - to define our own, we need to define a class that inherits from one of those built-in exception types. (We cover the details of classes and inherits in a few notebooks.)

The following code block creates a new exception type called MyException. The pass statement implies that the class is empty and will use the state and behavior of the parent class (Exception).

1class MyException(Exception):
2    pass

Based on some condition in our program, we could raise and catch this new exception type.

Note: The code does not have to catch the exception immediately. This example catches MyException immediately to demonstrate the capability.

1try:
2    raise MyException('panic')
3except MyException as exc:
4    print(exc)
panic

25.2.6. Stack Traces#

When a program does not catch an exception, the Python interpreter produces the stack trace (Traceback) of the function/method calls that led to the issue. It is also possible to programmatically process stack traces in code. For example, when the following code executes:

When the following code executes:

def a():
    print ("called")
    raise Exception("Showing something that happened")
    
def b():
    # some processing
    a()
    # more processing
    
print("starting")
b()
print("complete")

It produces the following result:

starting
called
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Input In [14], in &lt;cell line: 11&gt;()
      8     # more processing
     10 print("starting")
---> 11 b()
     12 print("complete")

Input In [14], in b()
      5 def b():
      6     # some processing
----> 7     a()

Input In [14], in a()
      1 def a():
      2     print ("called")
----> 3     raise Exception("Showing something that happened")

Exception: Showing something that happened

The very bottom of the code shows the line where an exception occurs. We can see the function name a and the lines immediately before the exception. We can then move upwards in the output to see the function that called a and so forth to the top. Similarly, we can start at the top. On line 11, our initial code routine called the function b. Then on line 7, b called function a. Then, on line 3 within a, an exception occurred.

Following the code through these stack traces (tracebacks) is essential to understanding why exceptions occurred and then developing code to prevent these issues.

25.2.7. Exception Best Practices#

As with any other programming technique, exceptions can be used wisely and abused.

Ideally, we should add exception handling anywhere an exception may occur. Nevertheless, we must catch and handle exceptions to resolve the underlying conditions. You should not just “use an exception to pass the buck”. Instead, determine the best place to handle the error. Sometimes this will be as close to the error as possible, other times it will be where the data was entered that eventually led to the exception. For exceptions caused by input data (e.g., a user entered the wrong file name), then the exception needs to be resolved in a way that the user can correct the issue - this may be several calls up the stack.

Also, for production code, we may want to include a default handler at the very top of our processing code to catch possible errors. With a default handler, we can log these errors for future investigation. Similarly, in processing loops for web applications, we may want to have a handle for the entry points. This handler can provide a more appropriate error message to the user and log unhandled exceptions.

Exceptions can notify other parts of the program to handle errors.

Throw exceptions only for truly exceptional conditions. If some other coding practice (validating input) can detect the condition, use that. For example, if we are computing the average of numbers in a list, we can check that the list is not empty to prevent a division by zero error.

When creating the exception, include all of the necessary details. For example, for an index out of bounds error with a list, you could document what variable held the list, the current size, and the requested index that was out of bounds. By having all these details, programmers can more easily debug the situation to determine what went wrong and then create more robust code to prevent future problems.

Avoid empty except blocks. Suppose the situation can handle the exception without any special processing. In that case, log the error message and comment on why no error handling was present.

25.3. Error Handling Techniques#

This section summarizes many different techniques to handle errors. The right approach differs based on the current situation. It may be appropriate to use several of these techniques in combination. [1]

Whatever mechanism(s) you utilize, document the error handling approach within function, module, or class by placing the information into docstrings.

25.3.1. Request new inputs#

Often, users generate errors through bad input values. This can range from a simple typo by a hurried administrator to a deliberate attack string from a malicious attacker. We can detect some of these input values through input validation logic, while others will require handling exceptions. We need to allow the users to recover from these mistakes by allowing them to correct their data entry mistakes and try again.

25.3.2. Return or use a neutral /default value#

One possibility is to return a value known to be harmless or devoid of meaning. For numbers, a numeric result might be zero. For strings, an empty string. For collections, return an empty collection.

Within routines, if a parameter is invalid, it may be better to default to a particular value or action. For example, a video game could show a default background color and let the operation continue.

25.3.3. Substitute the next piece of data#

If processing a stream or file of data, skip invalid records and return the next legitimate record. Often, we will combine this technique with “log the error”.

25.3.4. Substitute the previous data value#

Again, if processing a stream of data, such as temperature or heart rate, and you do not receive a valid value, one possibility is to return the most recent legitimate value.

However, you may also need to keep track of successive errors and raise a warning when a certain threshold (count) has passed.

25.3.6. Log the error#

You can log the error to a file when your program detects incorrect data and then continue processing. Often, you will want to use this method in conjunction with one of the other listed methods.

As you log data, consider any potential harm that data may cause. For example, was the data rejected because it could lead to an injection attack? Could the data leak sensitive or private information?

25.3.7. Return an error code#

Rather than the routine trying to handle an error itself, the routine can report an error and let the routine’s callers determine how to best respond to the situation. Several ways exist to perform this notification:

  • return an error status as the function’s return value.

  • raise an exception

  • set a status variable

25.3.8. Call an error-processing routine/class#

In this approach, programmers centralize error handling in a global function or class. By centralizing, the responsibility to handle errors only exists within one location. The primary advantage is that it becomes easier to investigate issues and debug problems. The downside to this approach is that the entire system becomes tethered to this capability.

25.3.9. Display an error message#

When an error occurs, alert the user to what happened, why it happened, and how to proceed.

Error messages

Providing well-crafted and helpful error messages is essential to any modern computer system. However, developers often fail to achieve that goal. For instance, Jupyter Lab produced the following error message::

Launcher Error Cannot read properties of undefined (reading 'path')
Error Invalid response: 400 Bad Request
Not helpful to say the least.

Good error messages need to be -

  • Explicit and visible. At an absolute minimum, users must know that something has gone awry. That email lost in the Internet? Good luck just finding that it has disappeared.

  • Human readable. Avoid obscure codes and abbreviations. It may be necessary to include a code to provide support, but that should come at the end of the message.

  • Polite phrasing. Please do not blame the user or imply that they did something wrong.

  • Accurate description. What exactly was the problem? In some security situations, such details may be inappropriate to provide.

  • Advice. Provide constructive advice on how to fix the problem or how to proceed with the next action.

Guidelines for Error Messages - Adobe
Guidelines for Error Messages - Nielson Norman Group

25.3.10. Be Situational#

Error handling is not a one-size fits all approach. Instead, choose the appropriate mechanism for the current situation.

While this approach provides flexibility, it may create risk in systems requiring certain robustness or safety levels.

25.3.11. Shut down#

Depending upon the situation, the best approach may be to generate an error log message and shut down the system immediately. For example, if you have software that controls radiation doses to patients as part of chemotherapy treatment, what should occur if an error situation arises? Using one of the techniques listed here for imputing data is not safe. The only safe alternative is to shut down until someone can appropriately investigate and resolve the situation.

Therac-25 is an often used example of this situation. Unfortunately, this radiation therapy machine killed several patients due to software issues. Nancy Leveson wrote an extensive paper on this situation.

25.4. Checking Parameter Values#

At the start of these notebooks, we presented the documentation to produce as we create an algorithm to solve a problem:

  • Inputs to the routine

  • Outputs from the routine

  • Pseudocode (steps in the routine)

Often, these inputs are a function’s parameters. As such, we should verify that these parameters meet the expected values. If the parameters are incorrect, we need to use one or more of the error-handling techniques.

For example, what issues may occur in the following method to compute the average of a list of values?

1def compute_average(l):
2    return sum(l)/len(l)

What happens if the data is not numeric? What happens if we pass an empty list?

1compute_average([100,90,80,"c"])     # raises type error, can not use '+' on numbers and strings
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 58 line 1
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y111sZmlsZQ%3D%3D?line=0'>1</a> compute_average([100,90,80,"c"])     # raises type error, can not use '+' on numbers and strings

/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 58 line 2
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y111sZmlsZQ%3D%3D?line=0'>1</a> def compute_average(l):
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y111sZmlsZQ%3D%3D?line=1'>2</a>     return sum(l)/len(l)

TypeError: unsupported operand type(s) for +: 'int' and 'str'
1compute_average([])          # raises division by zero error
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 59 line 1
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y112sZmlsZQ%3D%3D?line=0'>1</a> compute_average([])          # raises division by zero error

/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 59 line 2
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y112sZmlsZQ%3D%3D?line=0'>1</a> def compute_average(l):
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y112sZmlsZQ%3D%3D?line=1'>2</a>     return sum(l)/len(l)

ZeroDivisionError: division by zero

25.5. Preconditions, Postconditions, Invariants, and Assertions#

While this section title seems long, it is ultimately the same underlying principle at work in different ways - checking if a condition holds or not.

Building on top of the documentation for an algorithm, we can add two items: preconditions to the routine and postconditions of the routine.

Preconditions are conditions guaranteed to be true before the routine. These conditions include such things as inputs in appropriate ranges and files initialized and ready for input/output. Preconditions are the client code’s obligations to the routine that it calls. For example, if we create a shape such as a circle or a square, the radius or side length must be a nonnegative number. As we write code, we should check that these preconditions hold. If not, an appropriate error handling technique is needed, such as logging the issue, returning an error code, or raising an exception.

Postconditions are the conditions(items) guaranteed to be true before control returns to the caller. These conditions are the routine’s/class’s obligations to the code that uses it. For example, if a routine adds an element to a list, a postcondition is that the list is not empty. Or, more strongly, the length of a list is one more than the length of that list when the routine started.

Invariants are a set of conditions(assumptions) that must always hold true during the life of a program (or, more narrowly, by the life of an object). You can consider this to be a combined precondition and postcondition - it must be valid before and after a call to a method. However, while executing a routine that changes the relevant state/attributes for the condition, the invariant may not necessarily be true. Invariants should hold during the object’s lifetime - from the end of its constructor to the end of its destructor. (We will present the object’s lifcycle in a few notebooks.) As an example, the radius of a circle must always be nonnegative. We also apply the term invariant to a sequence of operations. In this user, the invariant is the set of conditions that remain true during these operations.

Assertions are a series of statements used during development that allows a program to check itself as it runs. Think of an assertion as a statement of fact*. As such, if an assertion fails, our assumption is incorrect, or the code produces an incorrect value. We use assertions as sanity checks that conditions hold true and to document assumptions we have made in the code explicitly. Primarily, we use assertions as a development/debugging tool - it is possible to turn off these checks completely. Do not use assert statements for necessary checks (data validation, authorization). However, as we will see in a couple of notebooks, we will also use assertions for our tests.

25.5.1. Assertion Example#

Suppose we build an online stock market system and need to compute the purchase price of a stock. As part of the business rules, the company can offer incentives such as a discount on the transaction cost for special promotions such as new customer enticement.

def compute_charge(num_shares, price_per_share, commission_charge, bonus_offer):
    amount = num_shares * price_per_share + commission_charge - bonus_offer
    return amount

However, what happens if bonus_offer > num_shares * price_per_share + commission_charge? Do we owe the client money due to the client buying stocks? In this situation, we need an assertion that the amount is positive. We could also add another assertion that the amount is less than or equal to the num_shares * price_per_share + commission_charge. (This covers negative values passed in bonus_offer.)

1def compute_charge(num_shares, price_per_share, commission_charge, bonus_offer):
2    amount = num_shares * price_per_share + commission_charge - bonus_offer
3    assert 0 <= amount <= num_shares * price_per_share + commission_charge
4    return amount
1compute_charge(100, 10.00, 7.95, 200.00)
807.95
1compute_charge(100, 10.00, 7.95, -200.00)  # Will raise an AssertionError as the bonus offer is negative
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 64 line 1
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y120sZmlsZQ%3D%3D?line=0'>1</a> compute_charge(100, 10.00, 7.95, -200.00)  # Will raise an AssertionError as the bonus offer is negative

/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 64 line 3
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y120sZmlsZQ%3D%3D?line=0'>1</a> def compute_charge(num_shares, price_per_share, commission_charge, bonus_offer):
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y120sZmlsZQ%3D%3D?line=1'>2</a>     amount = num_shares * price_per_share + commission_charge - bonus_offer
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y120sZmlsZQ%3D%3D?line=2'>3</a>     assert 0 <= amount <= num_shares * price_per_share + commission_charge
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y120sZmlsZQ%3D%3D?line=3'>4</a>     return amount

AssertionError: 
1compute_charge(100, 10.00, 7.95, 1500.00)  # raises an assertion error as amount < 0
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 65 line 1
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y121sZmlsZQ%3D%3D?line=0'>1</a> compute_charge(100, 10.00, 7.95, 1500.00)  # raises an assertion error as amount < 0

/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core Python/21-Validation,ExceptionsAndErrorHandling.ipynb Cell 65 line 3
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y121sZmlsZQ%3D%3D?line=0'>1</a> def compute_charge(num_shares, price_per_share, commission_charge, bonus_offer):
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y121sZmlsZQ%3D%3D?line=1'>2</a>     amount = num_shares * price_per_share + commission_charge - bonus_offer
----> <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y121sZmlsZQ%3D%3D?line=2'>3</a>     assert 0 <= amount <= num_shares * price_per_share + commission_charge
      <a href='vscode-notebook-cell:/Users/jbslanka/Documents/GitHub/jupyternotebooks/1-Core%20Python/21-Validation%2CExceptionsAndErrorHandling.ipynb#Y121sZmlsZQ%3D%3D?line=3'>4</a>     return amount

AssertionError: 

Fixing the last condition requires a check to be added to compute_charge that prevents a negative. For a negative bonus offer, we could choose among several different possibilities. We could add validation for each of the function’s parameters. We could also simply not choose to perform any validation as the call came from within our program.

1def compute_charge(num_shares, price_per_share, commission_charge, bonus_offer):
2    if bonus_offer < 0:
3        raise ValueError("bonus_offer cannot be negative")
4    amount = num_shares * price_per_share + commission_charge - bonus_offer
5    if amount < 0:
6        amount = 0
7    assert 0 <= amount <= num_shares * price_per_share + commission_charge
8    return amount
1compute_charge(100, 10.00, 7.95, 1500.00) 
0

25.5.2. Assertion Syntax#

The assert syntax follows this format:

    assert expression[, message]

At a minimum, assert needs an expression that evaluates to True or False. The second portion is optional and provides an error message if the assertion fails.

Assertions are an underutilized tool in many programmers’ arsenals. By documenting assumptions, asserts can help programmers identify situations that violate those assumptions. Pay careful attention, though, when using assertions - they are not a mechanism for detecting run-time errors, as they can be globally disabled through the Python interpreter. Also, do not use asserts to implement application functionality (e.g., data validation).

25.6. Discussion#

Programmers should strive to develop robust programs that can continue to operate in a safe state when issues do arise. If an executing program can not be put back into a safe state after an error, then the program must stop itself safely.

Trade-offs exist with the amount of error handling code added to our programs. In some cases, too much can be as much of a curse as too little. However, data validation is absolutely critical when it comes to validating external inputs into our systems - especially in online applications.

25.7. Suggested LLM Prompts#

  • Explain the concept of error handling and exception handling in Python. Discuss the differences between syntax errors (e.g., IndentationError), runtime errors (e.g., ZeroDivisionError), and logical errors, and provide examples of each.

  • What is the purpose of try-except blocks in Python? How can they be used to handle exceptions gracefully and prevent program crashes?

  • Discuss the importance of providing meaningful error messages to users and developers in Python applications. Provide examples of good and bad error messages using Python’s built-in exceptions or custom exceptions.

  • Explain the concept of error propagation in Python and how it relates to error handling. Discuss when it might be appropriate to propagate errors up the call stack using raise statements, and when it might be better to handle them locally.

  • What is the role of assertions in Python? Explain how assertions (assert statements) can be used to validate program state and enforce invariants.

  • Discuss the difference between preconditions, postconditions, and invariants in the context of assertions in Python. Provide examples of each and explain their significance.

  • Explain the concept of defensive programming in Python and how it relates to input validation, error handling, and assertions. Discuss the benefits and potential drawbacks of defensive programming in Python.

  • Discuss the role of error handling and input validation in ensuring software reliability and robustness in Python applications. Explain how these practices can help prevent system failures and improve overall system quality.

  • Explain the importance of documenting error handling and input validation strategies in Python projects. Discuss how documentation tools like docstrings can aid in maintainability, debugging, and knowledge transfer.

  • What is the role of exception hierarchies in Python? Explain how Python’s built-in exception hierarchy (e.g., BaseException, Exception, ArithmeticError) can be used to organize and handle different types of exceptions more effectively.

  • Discuss the concept of exception chaining in Python and its importance in error handling. Provide examples of how exception chaining using the raise … from … statement can aid in debugging and troubleshooting complex Python systems.

  • Explain the concept of resource management in the context of error handling in Python. Discuss how techniques like context managers (with statement) and try-with-resources can help ensure proper resource cleanup in the event of exceptions.

25.8. Review Questions#

  1. What is input validation, and why is it important in software?

  2. What is the difference between syntax errors, runtime errors, and logical errors? Provide examples of each.

  3. What is the purpose of try-except blocks or similar constructs in programming languages?

  4. Discuss the importance of providing clear and informative error messages to users and developers.

  5. What is error propagation, and when might it be appropriate to propagate errors up the call stack? How does this work with exceptions?

  6. Explain the role of assertions in software development, and how they can be used to validate program state and enforce invariants.

  7. Differentiate between preconditions, postconditions, and invariants in the context of assertions.

  8. What is the WORST possible way to deal with an error?

    1. Print an error message.
    2. Retry the operation.
    3. Crash the program.
    4. Fail silently.
    Explain your choice.
  9. Reliable software requires -

    1. error prevention
    2. error detection
    3. error recovery
    4. defect-free code
    5. assert statements
    Explain why each either applies or not.
  10. Should input validation be provided for internal sources (e.g., a file that was previously saved by the system)? Why or why not?

  11. How do you initiate a specific exception in Python? Provide a code example.

  12. In what order does that call stack appear in a Traceback?

  13. Should assertions be used to validate input parameters? Discuss the pros and cons of this approach.

  14. For multiple except blocks under a single try, in what order should the blocks be listed?

answers

25.9. Exercises#

  1. Rewrite the compute_average routine to handle non-numeric data, and an empty list is passed. Your function docstring should describe the applied error handling approach(es).

25.10. References#

  1. Steve McConnell. 2004. Code Complete, Second Edition. Microsoft Press, USA. O’Reilly

  2. Bjarne Stroustrup. 2014. Programming: Principles and Practice Using C++, Second Edition. Addison-Wesley, USA. O’Reilly