Designing Explicit Error Paths and Diagnostics
Strict shell options catch broad classes of mistakes. Production reliability comes from explicit error paths: clear classification, preserved status, actionable diagnostics, and a stable contract for callers.
Learning objectives
By the end of this lesson
- Classify expected failure types.
- Separate stdout results from stderr diagnostics.
- Preserve exact failure status.
- Return from reusable functions and exit at process boundaries.
- Combine strict modes with explicit error policy.
1. Error handling is an interface
A production script should answer four questions for every important failure: what failed, why it failed, what the caller can do next, and what exit status represents the outcome.
flowchart TD
O["operation"] --> Q{"success?"}
Q -->|"yes"| R["emit result"]
Q -->|"no"| C["classify failure"]
C --> D["diagnostic on stderr"]
D --> E["cleanup / rollback"]
E --> X["stable exit status"]2. Distinguish expected domain failures from operational failures
3. Diagnostics belong on stderr
printf 'artifact-id=%s\n' "$artifact_id"
printf 'error: upload failed endpoint=%s status=%d\n' \
"$endpoint" "$status" >&2Keeping stdout clean lets callers capture machine-readable results without mixing them with logs or errors.
4. Include enough context to identify the failing operation
printf 'error: deploy failed service=%q env=%q status=%d\n' \
"$service" "$environment" "$status" >&2Context should identify the resource and action, but avoid secrets, tokens, credentials, or unnecessary user data.
5. Keep exit codes small and stable
# Example contract:
# 0 success
# 64 invalid invocation
# 65 policy/domain rejection
# 69 dependency unavailable
# 70 internal/operational failureNumeric statuses are for automation; the human-readable stderr message should explain the problem.
6. Centralize repeated diagnostic structure
die() {
local status=$1
shift
printf 'error: %s\n' "$*" >&2
exit "$status"
}
require_command() {
command -v "$1" >/dev/null 2>&1 ||
die 69 "required command not found: $1"
}A die helper is useful at top-level fatal boundaries. Deep library-style functions are often easier to reuse if they return and let the caller decide whether to exit.
7. Functions should return status; callers should decide policy
validate_service() {
local service=$1
[[ $service =~ ^[a-z0-9.-]+$ ]] || return 65
}
if validate_service "$service"; then
:
else
status=$?
printf 'invalid service identifier: %q\n' "$service" >&2
exit "$status"
fi8. Save status before logging or cleanup
if external_tool "$input"; then
:
else
status=$?
cleanup_partial_state
printf 'external tool failed status=%d\n' "$status" >&2
exit "$status"
fiRunning another command before saving $? destroys the original failure code.
9. Cleanup and rollback are different concepts
Cleanup removes temporary resources. Rollback attempts to restore externally visible state. Rollback may fail too, so diagnostics should preserve the primary failure while reporting rollback problems separately.
10. Strict modes support explicit error handling; they do not replace it
set -u
set -o pipefail
# set -e may also be used as a guardrail
main "$@"Use strictness options to catch broad classes of bugs, then write explicit branches around operations where recovery, classification, or detailed diagnostics matter.
11. Put orchestration in main
main() {
parse_args "$@" || return $?
validate_config || return $?
run_operation || return $?
}
if main "$@"; then
exit 0
else
status=$?
printf 'operation failed status=%d\n' "$status" >&2
exit "$status"
fiA top-level main gives the script one clear place to convert internal statuses into process exit behavior.
12. Hands-on lab: explicit error-contract CLI
mkdir -p "$HOME/devops-academy/bash/chapter11/lesson05"
cd "$HOME/devops-academy/bash/chapter11/lesson05"
cat > release-check.sh <<'EOF'
#!/usr/bin/env bash
set -u
set -o pipefail
die() {
local status=$1
shift
printf 'error: %s\n' "$*" >&2
exit "$status"
}
service=${1:-}
environment=${2:-}
[[ -n $service && -n $environment ]] ||
die 64 "usage: $0 SERVICE ENVIRONMENT"
case $environment in
dev|staging|prod) ;;
*) die 65 "unsupported environment: $environment" ;;
esac
command -v grep >/dev/null 2>&1 ||
die 69 "required command missing: grep"
if printf '%s\n' "$service" |
grep -Eq '^[a-z0-9][a-z0-9.-]*$'; then
printf 'VALID service=%s environment=%s\n' \
"$service" "$environment"
else
status=$?
die 65 "invalid service identifier (grep status=$status)"
fi
EOF
chmod u+x release-check.sh
./release-check.sh api staging
./release-check.sh 'API PROD' prod || true
./release-check.sh api qa || trueVerification checklist
13. Knowledge check
Question 1. Why keep stdout and stderr separate?
Question 2. When should a reusable function return instead of exit?
Question 3. What is the difference between cleanup and rollback?
Question 4. Do strict modes eliminate the need for explicit error branches?
14. Summary
Production error handling is explicit: classify failures, preserve exact status, emit actionable diagnostics on stderr, keep stdout clean, centralize process-level exit policy, and use strict modes as guardrails rather than substitutes for control flow.
15. Further reading
- GNU Bash Reference Manual — Exit Status and Shell Builtins.
- POSIX shell command language — exit status conventions.
- Command Line Interface Guidelines (clig.dev) — errors and output streams.
- ShellCheck documentation — error-path and quoting guidance.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.