Lesson 8: Error Handling & Debugging

Goal: Write robust scripts that handle errors gracefully and are easy to debug.


Table of Contents

  1. Exit Codes
  2. set Flags — Strict Mode
  3. trap — Cleanup & Signal Handling
  4. Error Handling Patterns
  5. Debugging Techniques
  6. Exercises

Exit Codes

Every command returns an exit code: 0 = success, 1-255 = error.

ls /etc/passwd
echo $?    # 0 (success)

ls /nonexistent
echo $?    # 2 (error)

Custom exit codes

#!/bin/bash

validate_input() {
    if [[ -z "${1:-}" ]]; then
        echo "Error: No input provided" >&2
        exit 1
    fi
    if [[ ! -f "$1" ]]; then
        echo "Error: File not found: $1" >&2
        exit 2
    fi
}

validate_input "$@"
echo "Processing: $1"

Convention

Code Meaning
0 Success
1 General error
2 Misuse of command
126 Permission denied
127 Command not found
128+N Killed by signal N
130 Killed by Ctrl+C (SIGINT)

set Flags — Strict Mode

#!/bin/bash
set -euo pipefail

Handling expected failures with set -e

set -e

# This would exit the script — but maybe you WANT to check:
if grep -q "pattern" file.txt; then
    echo "Found"
else
    echo "Not found"  # This is fine — grep failure is handled
fi

# Or use || true to suppress
count=$(grep -c "missing" file.txt || true)

# Or use || with a handler
rm /protected/file || echo "Could not delete (expected)"

trap — Cleanup & Signal Handling

trap runs a command when the script receives a signal or exits.

Cleanup on exit

#!/bin/bash
set -euo pipefail

TMP_DIR=$(mktemp -d)

cleanup() {
    echo "Cleaning up: $TMP_DIR"
    rm -rf "$TMP_DIR"
}
trap cleanup EXIT

# Work with temp files — cleanup happens automatically
echo "data" > "$TMP_DIR/file.txt"
echo "Working in $TMP_DIR"
# Even if the script fails, cleanup runs

Catch specific signals

#!/bin/bash

on_sigint() {
    echo ""
    echo "Caught Ctrl+C — shutting down gracefully..."
    exit 130
}
trap on_sigint SIGINT

echo "Running... (Press Ctrl+C to stop)"
while true; do
    sleep 1
done

Error handler with line number

#!/bin/bash
set -euo pipefail

on_error() {
    echo "Error on line $1 (exit code: $2)" >&2
    exit "$2"
}
trap 'on_error ${LINENO} $?' ERR

echo "Line 1: OK"
echo "Line 2: OK"
false  # This triggers the error handler
echo "Line 4: Never reached"

Combined trap

#!/bin/bash
set -euo pipefail

TMP_DIR=$(mktemp -d)
LOG_FILE="$TMP_DIR/output.log"

cleanup() {
    local exit_code=$?
    if [[ $exit_code -ne 0 ]]; then
        echo "Script failed (exit code: $exit_code)" >&2
        [[ -f "$LOG_FILE" ]] && echo "Last log entries:" && tail -5 "$LOG_FILE"
    fi
    rm -rf "$TMP_DIR"
}
trap cleanup EXIT

Error Handling Patterns

Die function

die() {
    echo "[FATAL] $*" >&2
    exit 1
}

[[ -f "config.yaml" ]] || die "Config file not found"
command -v docker &>/dev/null || die "Docker is required"

Try/catch pattern

try() {
    "$@" 2>/dev/null
    return $?
}

if try curl -sf https://api.example.com/health; then
    echo "API is healthy"
else
    echo "API is down"
fi

Retry with backoff

retry_backoff() {
    local max_attempts="$1"
    shift
    local attempt=1
    local delay=1

    until "$@"; do
        if ((attempt >= max_attempts)); then
            echo "Failed after $max_attempts attempts" >&2
            return 1
        fi
        echo "Attempt $attempt failed. Retrying in ${delay}s..."
        sleep "$delay"
        ((attempt++))
        ((delay *= 2))  # exponential backoff
    done
}

retry_backoff 5 curl -sf https://api.example.com/health

Assert functions

assert_file_exists() {
    [[ -f "$1" ]] || { echo "Assert failed: file '$1' not found" >&2; exit 1; }
}

assert_not_empty() {
    [[ -n "$1" ]] || { echo "Assert failed: variable is empty" >&2; exit 1; }
}

assert_command() {
    command -v "$1" &>/dev/null || { echo "Assert failed: '$1' not installed" >&2; exit 1; }
}

# Usage
assert_command docker
assert_file_exists ./docker-compose.yml
assert_not_empty "${DB_HOST:-}"

Debugging Techniques

bash -x — Trace execution

bash -x script.sh

Shows every command before it runs:

+ set -euo pipefail
+ NAME=Benjamin
+ echo 'Hello, Benjamin'
Hello, Benjamin

Enable tracing inside a script

#!/bin/bash
set -x  # Enable tracing

echo "Step 1"
ls /tmp

set +x  # Disable tracing
echo "No more tracing"

Selective debugging

#!/bin/bash

debug() {
    [[ "${DEBUG:-false}" == "true" ]] && echo "[DEBUG] $*" >&2
}

debug "Starting script"
debug "Variable FOO=$FOO"

# Run with: DEBUG=true ./script.sh

PS4 — Custom trace prefix

#!/bin/bash
export PS4='+[${BASH_SOURCE}:${LINENO}] '
set -x

echo "hello"
# Output: +[script.sh:5] echo hello

Validate with shellcheck

# Install
sudo apt install shellcheck

# Check a script
shellcheck script.sh

ShellCheck catches common mistakes: unquoted variables, useless cats, deprecated syntax, etc.


Exercises

Exercise 1: Robust script with trap

cat > robust.sh << 'EOF'
#!/bin/bash
set -euo pipefail

TMP=$(mktemp -d)
trap 'echo "Cleanup: removing $TMP"; rm -rf "$TMP"' EXIT

echo "Working in: $TMP"
echo "important data" > "$TMP/data.txt"
echo "File created: $(cat "$TMP/data.txt")"

# Simulate an error
echo "About to fail..."
false

echo "This never runs"
EOF

chmod +x robust.sh && ./robust.sh || true
rm robust.sh

Exercise 2: Error handler with context

cat > errhandler.sh << 'EOF'
#!/bin/bash
set -euo pipefail

on_error() {
    echo ""
    echo "=== ERROR ===" >&2
    echo "Script: $0" >&2
    echo "Line:   $1" >&2
    echo "Code:   $2" >&2
    echo "=============" >&2
}
trap 'on_error ${LINENO} $?' ERR

echo "Step 1: OK"
echo "Step 2: OK"
echo "Step 3: About to fail..."
cat /this/does/not/exist
echo "Step 4: Never reached"
EOF

chmod +x errhandler.sh && ./errhandler.sh 2>&1 || true
rm errhandler.sh

Key Takeaways

  • set -euo pipefail — always use strict mode
  • $? — check exit code of last command
  • trap 'cleanup' EXIT — auto-cleanup, even on errors
  • trap 'handler' ERR — custom error reporting with line numbers
  • || true to suppress expected failures under set -e
  • bash -x / set -x for trace debugging
  • shellcheck — static analysis, catches bugs before runtime
  • Retry with exponential backoff for network operations

Next Lesson: Lesson 9: Input, Arguments & Options →