Writing Bash Scripts Like A Pro - Part 2 - Error Handling

8 mins
Published on 29 July 2023

In the previous blog post of this series, we covered the basics of how to write a proper Bash script, with a consistent naming convention. Writing Bash scripts consistently while learning new terms can be a significant challenge when doing it from scratch. But fear not! We’re here to guide you on becoming a Bash scripting master!

And the journey of loving Bash continues!

Definition of Done

Before diving into the fascinating error-handling world in Bash scripts, let’s set the “definition of done.” By the end of this blog post, we aim to achieve the following:

  1. Get familiar with Bash’s exit codes.
  2. Take advantage of STDERR and STDOUT to handle errors.
  3. Allow script execution to continue even if there’s an error.
  4. Invoke an error handler (function) according to an error message.

Handling Errors Like a Pro: Understanding Exit Codes

Bash scripts return an exit status or exit code after execution. An exit code 0 means success, while a non-zero exit code indicates an error. Understanding exit codes is fundamental to effective error handling.

When a command succeeds, it returns an exit code of 0, indicating success:

#!/usr/bin/env bash

ls "$HOME"
echo $? # Print the exit code of the last command

# Exit code 0 indicates success
/path/to/home/dir
0

If the ls command fails, it returns a non-zero exit code, indicating an error:

#!/usr/bin/env bash

ls /path/to/non-existent/directory
echo $? # Print the exit code of the last command

# Exit code non-zero indicates an error
ls: /path/to/non-existent/directory: No such file or directory
1

Using Exit Codes to Our Advantage

One way to handle errors is by adding the set -e option to your Bash scripts. When enabled, it ensures that the script will terminate immediately if any command exits with a non-zero status. It’s like a safety net that automatically catches errors and stops the script from continuing.

#!/usr/bin/env bash

# Stop execution on any error
set -e

echo "This line will be printed."

# Simulate an error
ls /nonexistent-directory

echo "This line will NOT be printed."
This line will be printed.
ls: /nonexistent-directory: No such file or directory

In this example, the ls command attempts to list the contents of a non-existent directory, causing an error. Due to set -e, the script will stop executing after encountering the error, and the last line won’t be printed.

Recap on new terms

We covered a few new characters and terms, so let’s make sure we fully understand what they do:

  • The $HOME variable exists on any POSIX system, so I used it to demonstrate how Bash can use a Global Environment Variable that contains your “home directory path”.
  • The $? character is an exit status variable which stores the exit code of the previous command.
  • The option set -e forces the script to stop executing when encountering any error.

Redirecting STDERR and STDOUT: Capturing Errors

Often, you might want to capture the output (both STDOUT and STDERR) of a command and handle it differently based on whether it succeeded or failed (raised an error).

We can use the expression $(subcommand) to execute a command and then capture its output into a variable. The important part is to redirect STDERR to STDOUT by adding 2>&1 to the end of the “subcommand”.

#!/usr/bin/env bash

# Run the 'ls' command and redirect both STDOUT and STDERR to the 'response' variable
response="$(ls /nonexistent-directory 2>&1)"
# Did NOT set `set -e`, hence script continues even if there's an error
if [[ $? -eq 0 ]]; then
    echo "Success: $response"
else
    echo "Error: $response"
fi
Error: ls: /nonexistent-directory: No such file or directory

In this example, the ls command attempts to list the contents of a non-existent directory. The output (including the error message) is captured in the response variable. We then check the exit status using $? and print either "Success: $response" or "Error: $response" accordingly.

Allowing Script Execution Despite Errors

So far, we covered set -e to terminate execution on an error and redirect error output to standard output 2>&1, but what happens if we combine them?

You may want to continue executing the script even if a command fails and save the error message for later use in an error handler. The || true technique comes to the rescue! It allows the script to proceed without terminating, even if a command exits with a non-zero status. That is an excellent technique for handling errors according to their content.

Ping Servers Scenario

In the following example, we attempt to ping each server, with a timeout of 1 second, using the ping command. If the server is reachable, we should print “Response - ${response}.”. Though what happens if the ping fails? How do we handle that? Let’s solve it with a use-case scenario!

To make it authentic as possible, I added an array of servers with the variable_name=() expression.

After that, I used the for do; something; done loop to iterate over the servers. For each iteration, we ping a server and redirect STDERR to STDOUT to capture the error’s output to the variable response.

And the final tweak was to add || true inside the $() evaluation so that even if the ping command fails, its output is saved in the response variable.

#!/usr/bin/env bash

# PARTIAL SOLUTION - do not copy paste

# Stop execution on any error
set -e


# Creates an array, values are delimited by spaces
servers=("google.com" "netflix.com" "localhost:1234")


for item in "${servers[@]}"; do
    # Use the 'ping' command and redirect both STDOUT and STDERR to the 'response' variable
    response="$(ping -c 1 -t 1 "$item" 2>&1 || true)"

    # TODO: Fix, always evaluates as true
    if [[ $? -eq 0 ]]; then
        echo "
Response for ${item}:
--------------------------------------------------------------
${response}
--------------------------------------------------------------"
    else
        echo "Error - ${response}"
    fi
done
Response for google.com:
--------------------------------------------------------------
PING google.com (172.217.22.14): 56 data bytes
64 bytes from 172.217.22.14: icmp_seq=0 ttl=56 time=126.156 ms

--- google.com ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 126.156/126.156/126.156/0.000 ms
--------------------------------------------------------------

Response for netflix.com: <------- Sholuld've been Error not Response
--------------------------------------------------------------
PING netflix.com (3.251.50.149): 56 data bytes

--- netflix.com ping statistics ---
1 packets transmitted, 0 packets received, 100.0% packet loss
--------------------------------------------------------------

Response for localhost:1234: <------- Sholuld've been Error not Response
--------------------------------------------------------------
ping: cannot resolve localhost:1234: Unknown host
--------------------------------------------------------------

The response will always be evaluated as true because of this part:

# ...
    response="$(ping -c 1 -t 1 "$item" 2>&1 || true)"
    # <-- At this point, `$?` is always `0` because of `|| true`

    # Will constantly evaluate as `true`, 0 = 0
    if [ $? -eq 0 ]; then
# ...

The best way to fix it is to analyze a successful response message and set it as an “indicator of a successful response”, and in any other case, the script should fail with an error message.

In the case of running ping -c 1 -t1 $item, a successful response can be considered as:

# Good response, according to a tested output
1 packets transmitted, 1 packets received, 0.0% packet loss

The analysis should be done for a specific use case; this approach assists with handling unknown errors by setting a single source of truth for a successful response and considering anything else as an error.

Here’s the final version of the code, with a few upgrades to the output:

#!/usr/bin/env bash

# Good example

# Stop execution on any error
set -e


servers=("google.com" "netflix.com" "localhost:1234")


for item in "${servers[@]}"; do
    # Use the 'ping' command and redirect both STDOUT and STDERR to the 'response' variable
    response=$(ping -c 1 "$item" -t 1 2>&1 || true)

    # The condition is based on what we consider a successful response
    if echo "$response" | grep "1 packets transmitted, 1 packets received, 0.0% packet loss" 1>/dev/null 2>/dev/null ; then
        echo "
SUCCESS :: ${item}
--------------------------------------------------------------
${response}
--------------------------------------------------------------"
    else
        echo "
ERROR :: ${item}
--------------------------------------------------------------
${response}
--------------------------------------------------------------"
    fi
done
SUCCESS :: google.com
--------------------------------------------------------------
PING google.com (172.217.22.14): 56 data bytes
64 bytes from 172.217.22.14: icmp_seq=0 ttl=56 time=159.311 ms

--- google.com ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 159.311/159.311/159.311/0.000 ms
--------------------------------------------------------------

ERROR :: netflix.com
--------------------------------------------------------------
PING netflix.com (54.246.79.9): 56 data bytes

--- netflix.com ping statistics ---
1 packets transmitted, 0 packets received, 100.0% packet loss
--------------------------------------------------------------

ERROR :: localhost:1234
--------------------------------------------------------------
ping: cannot resolve localhost:1234: Unknown host
--------------------------------------------------------------

What the grep?

You’ve just learned how to use a [Bash pipe](https://tldp.org/HOWTO/Bash-Prog-Intro-HOWTO-4.html) to pass data to the grep command. The trick is to echo ${a_variable} and pipe it with | to the grep command like this:
response="some response"
echo "$response" | grep "some" 1>/dev/null 2>/dev/null # Success
echo $?
echo "$response" | grep "not-in-text" 1>/dev/null 2>/dev/null # Fail
echo $?
0
1

You’ve also learned about /dev/null, a black hole where you can redirect output that shouldn’t be printed or saved anywhere.

Implementing Custom Error Handlers

A more complex scenario may require dedicated error handlers, for example, executing an HTTP Request with curl, and handling HTTP Responses; You can create a custom function to handle specific responses gracefully, like this:

#!/usr/bin/env bash

# Stop execution on any error
set -e


handle_api_error() {
    local msg="$1"
    case $msg in
        '{"message":"Not Found","code":404}') # In case of page not found
            echo "Error: Resource not found!"
            exit 4 # Bash exit code
            ;;
        *) # Any other case
            echo "Error: Unknown API error with message ${msg}."
            ;;
    esac

    # Exit either way
    exit 1
}


# Make a request to the API and store the response text in the 'response' variable
response="$(curl -s https://catfact.ninja/fact 2>&1 || true)"


# According to a successful response, having `"fact":` is a good indicator
# If `"fact"` does not `!` appear in the message, it's an error
if ! echo "$response" | grep "\"fact\":" 1>/dev/null ; then
    handle_api_error "$response"
fi

# At this point, we are sure the response is valid
echo "Response: ${response}"
# For this specific API, a successful response returns a random fact about cats
Response: {"fact":"Neutering a male cat will, in almost all cases, stop him from spraying (territorial marking), fighting with other males (at least over females), as well as lengthen his life and improve its quality.","length":198}

Final Words

This blog post took it up a notch; you’ve learned several terms and can now handle errors in Bash like a Pro! There are still more tricks in this error-handling mix, like trapping CTRL-C error; we’ll discover more about that and other ways to handle errors using Bash scripts.

Related Posts