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:
- Get familiar with Bash's exit codes.
- Take advantage of STDERR and STDOUT to handle errors.
- Allow script execution to continue even if there's an error.
- 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 | 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.
Top comments (0)