Writing Bash Scripts Like A Pro - Part 2 - Error Handling
Tags: bash, programming, tutorial, devops
Published: 2023-07-29 22:02:19

In the previous [blog post](https://dev.to/unfor19/writing-bash-scripts-like-a-pro-part-1-styling-guide-4bin) of this series, we covered the basics of how to write a proper [Bash](https://www.gnu.org/software/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](https://tldp.org/LDP/abs/html/exitcodes.html).
2. Take advantage of [STDERR and STDOUT](https://tldp.org/HOWTO/Bash-Prog-Intro-HOWTO-3.html) to [handle errors](https://www.cyberciti.biz/faq/redirecting-stderr-to-stdout/).
3. Allow script execution to continue even if there's an error.
4. Invoke an error handler ([function](https://tldp.org/HOWTO/Bash-Prog-Intro-HOWTO-8.html)) 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:

```bash
#!/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](https://tldp.org/LDP/abs/html/basic.html) command fails, it returns a non-zero exit code, indicating an error:

```bash
#!/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.

```bash
#!/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](https://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap08.html) variable exists on any [POSIX](https://en.wikipedia.org/wiki/POSIX) system, so I used it to demonstrate how Bash can use a [Global Environment Variable](https://tldp.org/LDP/Bash-Beginners-Guide/html/sect_03_02.html) that contains your "home directory path".
- The [$?](https://tldp.org/LDP/abs/html/special-chars.html) character is an _exit status variable_ which stores the exit code of the previous command.
- The option [set -e](https://tldp.org/LDP/abs/html/options.html#:~:text=errexit) 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](https://tldp.org/HOWTO/Bash-Prog-Intro-HOWTO-3.html)) of a command and handle it differently based on whether it succeeded or failed (raised an error).

We can use the expression [$(subcommand)](https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Command-Substitution) 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".

```bash
#!/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](https://tldp.org/HOWTO/Ethernet-Bridge-netfilter-HOWTO-4.html) 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](https://tldp.org/LDP/abs/html/arrays.html#:~:text=empty%20arrays%20and%20empty%20elements) of servers with the `variable_name=()` expression.

After that, I used the [for do; something; done](https://tldp.org/HOWTO/Bash-Prog-Intro-HOWTO-7.html) 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.

```bash
#!/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:

```bash
# ...
    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:

```bash
# 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:

```bash
#!/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](https://tldp.org/LDP/Bash-Beginners-Guide/html/sect_04_02.html). The trick is to `echo ${a_variable}` and pipe it with `|` to the `grep` command like this:

```bash
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](https://tldp.org/LDP/abs/html/zeros.html), 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) with [curl](https://curl.se/), and handling [HTTP Responses](https://developer.mozilla.org/en-US/docs/Web/API/Response); You can create a custom function to handle specific responses gracefully, like this:

```bash
#!/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}"
```

```bash
# 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](https://rimuhosting.com/knowledgebase/linux/misc/trapping-ctrl-c-in-bash); we'll discover more about that and other ways to handle errors using Bash scripts.