six demon bag

Wind, fire, all that kind of thing!

2020-03-06

Shell Patterns (2) - Error Handling

This is a short series describing some Bash constructs that I frequently use in my scripts.

When writing scripts for automation purposes you normally want the scripts to terminate when something goes wrong. Because terminating in a controlled way is usually better than blindly continuing execution when the assumptions subsequent commands are based on aren't valid anymore.

Bash provides several options for controlling error handling, the most commonly used ones being

  • -e (or -o errexit): Exit immediately when a command terminates with a non-zero exit code.
  • -u (or -o nounset): Treat unset variables and parameters (except for $@ and $*) as errors when expanding them. This prevents problems due to misspelled variables.

There are some issues with using just these two options, though:


  • Only a non-zero exit status of the rightmost command in a pipeline will cause an error.

    #!/bin/bash
    set -eu
    echo '1'
    echo 'foo' | grep 'bar' | head -n 1  # this line doesn't error out
    echo '2'
    echo 'foo' | grep 'bar'              # but this line does.
    echo '3'
    
  • If a command just returns a non-zero exit code without also producing error output you have no indication where the actual error occurred. For example, the below script will fail in line 4, but won't indicate the failing statement.

    #!/bin/bash
    set -eu
    echo '1'
    echo 'foo' | grep 'bar'  # this fails silently
    echo '2'
    
  • Some commands return non-zero exit codes for non-error status information. In the example below grep exits with status 1 because it cannot find the string "bar" in the input string "foo", but the result isn't necessarily indicative of an error.

    echo 'foo' | grep 'bar'
    echo "$?"
    

    Another example is incrementing or decrementing a counter, which will return a non-zero exit code when the variable is changed from zero to a non-zero value.

    i=0
    (( i++ ))
    echo "$?"   # output: 1
    (( i++ ))
    echo "$?"   # output: 0
    

The first issue can be addressed by adding the option pipefail, which makes the return value of a pipeline either zero (if all commands returned 0) or the exit code of the rightmost command that exited with a non-zero exit code. The below code will exit with the status 1 because grep cannot find the string "bar" in the input string and therefore returns 1, even though the last command in the pipeline (head) still returns 0.

#!/bin/bash
set -euo pipefail
echo 'foo' | grep 'bar' | head -n 1

For handling the third issue you'd force the status to "success" by conditionally executing the command true if the command or pipeline terminated with a non-zero exit code.

#!/bin/bash
set -euo pipefail
echo 'foo' | grep 'bar' | head -n 1 || true

Which leaves us with the second issue: silent errors. These can be handled by trapping ERR events:

#!/bin/bash
set -euo pipefail
trap 'echo "an error occurred"' ERR
echo 'foo' | grep 'bar' | head -n 1   # output: "an error occurred"

Of course, just displaying a text message like "an error occurred" isn't too useful, so you'd define an error handling function, which the trap invokes with relevant information about the error. For me that information is usually

  • the name of the script ($0),
  • the exit code of the failed command ($?),
  • the number of the line in which the error occurred ($LINENO),
  • the name of the function in which the error occurred ($FUNCNAME[0]), and
  • the actual statement that raised the error ($BASH_COMMAND).

You'll also need to add the option -E (or -o errtrace), so that the trap is inherited by functions, command substitutions, and subshells, otherwise errors in those wouldn't trigger the error handler. And I usually throw in -T (or -o functrace) for good measure, so that traps on DEBUG and RETURN events are inherited as well. To avoid replacing an existing error handler check for already existing traps on ERR events before defining the trap.

error_handler() {
  echo "Unexpected error ${1} in ${2}, line ${3}, ${4}(): ${5}"
  exit "${1:-1}"
}

if [ -z "$(trap -p ERR)" ]; then
  set -eETuo pipefail
  trap 'error_handler "$?" "$0" "$LINENO" "${FUNCNAME[0]}" "$BASH_COMMAND"' ERR
fi

Posted 17:25 [permalink]