Healthcheck V2 — Hardening Script
v1 worked for the happy path and failed everything else — no timeout, no retries, no way to tell a connection failure apart from an unhealthy response, no logging beyond a bare echo, and no protection against spamming an alert on every single run during a real outage. This chapter fixes all of it, evolving the exact same script.
Adding A Timeout
Straight from last chapter’s preview, actually integrated this time:
max_time=5
response=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$max_time" "$url")A service that never actively refuses the connection, but never responds either, no longer hangs this script indefinitely — curl gives up after max_time seconds and reports failure instead.
Adding Retries
A single failed attempt shouldn’t immediately mean “the service is down” — a brief network blip is common and usually resolves itself within a few seconds:
max_attempts=3
attempt=1
response=""
while (( attempt <= max_attempts )); do
if response=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$max_time" "$url"); then
[[ "$response" == "200" ]] && break
fi
(( attempt < max_attempts )) && sleep $(( attempt * 2 ))
(( attempt++ ))
doneThis is a close relative of the retry_with_backoff function from the Real-World Patterns chapter, hand-adapted here because this loop needs to log something different depending on which kind of failure occurred at each attempt — covered next.
Distinguishing Connection Failure From An Unhealthy Response
if response=$(curl ...); then is doing more work than it might look like. Note this is a plain assignment, not a local one — and that distinction matters directly: local var=$(cmd) masks the command substitution’s real exit status behind local’s own success. A plain assignment, without local, has no such problem — response=$(curl ...)’s own exit status genuinely is curl’s exit status, which is exactly what makes it usable directly as an if condition here.
A connection failure (DNS failure, refused connection, or the new timeout) makes curl itself exit nonzero, caught by this if; a successful connection returning an unhealthy status code still makes curl exit 0 (it successfully retrieved something), just with response holding something other than 200. These are genuinely different failure modes, and this pattern tells them apart cleanly.
Adding Structured Logging
Reusing the exact leveled logging function from the backup project:
log() {
local level="$1"
shift
local ts
ts=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$ts] [$level] $*" >&2
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }Rate-Limiting Alerts
A small state file records when the last alert actually fired, letting the script suppress repeat alerts for the same ongoing outage while still alerting immediately on a new one:
alert_state_file="/tmp/healthcheck-last-alert.state"
min_alert_interval=300
should_alert() {
[[ ! -f "$alert_state_file" ]] && return 0
local last_alert
last_alert=$(cat "$alert_state_file")
local now
now=$(date +%s)
(( now - last_alert >= min_alert_interval ))
}
record_alert() {
date +%s > "$alert_state_file"
}On a successful check, the state file is removed entirely — so the next real failure, whenever it happens, alerts immediately rather than being suppressed by a stale record of a long-resolved incident.
The Full v2 Script
#!/usr/bin/env bash
set -euo pipefail
readonly EXIT_INVALID_ARGS=2
readonly EXIT_UNHEALTHY=1
url="${1:-}"
max_time=5
max_attempts=3
alert_state_file="/tmp/healthcheck-last-alert.state"
min_alert_interval=300
log() {
local level="$1"
shift
local ts
ts=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$ts] [$level] $*" >&2
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
if [[ -z "$url" ]]; then
log_error "Usage: $0 <url>"
exit "$EXIT_INVALID_ARGS"
fi
should_alert() {
[[ ! -f "$alert_state_file" ]] && return 0
local last_alert
last_alert=$(cat "$alert_state_file")
local now
now=$(date +%s)
(( now - last_alert >= min_alert_interval ))
}
record_alert() {
date +%s > "$alert_state_file"
}
attempt=1
response=""
while (( attempt <= max_attempts )); do
if response=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$max_time" "$url"); then
if [[ "$response" == "200" ]]; then
log_info "Attempt $attempt: OK ($response)"
break
else
log_warn "Attempt $attempt: unhealthy status $response"
fi
else
log_warn "Attempt $attempt: connection failed"
response=""
fi
(( attempt < max_attempts )) && sleep $(( attempt * 2 ))
(( attempt++ ))
done
if [[ "$response" == "200" ]]; then
log_info "Service healthy: $url"
rm -f "$alert_state_file"
exit 0
fi
if [[ -z "$response" ]]; then
log_error "Service unreachable after $max_attempts attempts: $url"
else
log_error "Service unhealthy after $max_attempts attempts: $url (last status: $response)"
fi
if should_alert; then
log_error "ALERT: $url is down"
record_alert
else
log_warn "Suppressing repeat alert (last alert within ${min_alert_interval}s)"
fi
exit "$EXIT_UNHEALTHY"What Changed From v1
--max-timeon everycurlcall, closing v1’s most dangerous gap.- Up to three attempts with increasing delay, absorbing brief transient failures instead of alerting on every one.
- Connection failure and unhealthy response are now logged distinctly, using a plain (not
local) assignment’s genuine exit status. - Leveled, timestamped logging throughout, replacing v1’s single bare
echo. - Rate-limited alerting, with automatic recovery-triggered reset via the removed state file.
Best Practices
- Always set an explicit timeout on any network call in a script — the default OS-level timeout, if there even is one, is rarely what you actually want.
- Use a plain assignment (not
local) when a command substitution’s own exit status needs to be checked directly — applied deliberately rather than accidentally. - Reset any rate-limiting state on recovery, not just on the next failure — otherwise a resolved incident’s suppression window can mask the start of a completely unrelated new one.
Shell-Safety Considerations
The rate-limiting logic above has a real gap this script hasn’t addressed at all: nothing prevents two overlapping runs from both deciding to alert at the same time.
The problem, demonstrated directly — two “runs” checking should_alert before either has recorded anything:
alert_state_file="/tmp/shell-course-demo-alert.state"
rm -f "$alert_state_file"
should_alert() {
[[ ! -f "$alert_state_file" ]] && return 0
local last_alert
last_alert=$(cat "$alert_state_file")
local now
now=$(date +%s)
(( now - last_alert >= 300 ))
}
record_alert() { date +%s > "$alert_state_file"; }
if should_alert; then echo "Run A: would alert"; fi
if should_alert; then echo "Run B: would alert"; fi
record_alertRun A: would alert
Run B: would alertBoth runs saw no state file and both decided independently to alert — exactly what would happen if a scheduled run overlapped with a still-in-progress previous one (increasingly likely now that retries make a single run take several seconds longer than it used to). Whoever’s on the receiving end gets duplicate alerts for the same single incident.
The fix is the same lockfile pattern the backup project built in full — applied here to ensure only one instance of this script’s check-and-alert logic runs at a time:
lock_file="/tmp/shell-course-demo-healthcheck.lock"
if [[ -f "$lock_file" ]]; then
existing_pid=$(cat "$lock_file")
if kill -0 "$existing_pid" 2>/dev/null; then
echo "Already running as PID $existing_pid — skipping this run"
exit 0
fi
fi
echo "$$" > "$lock_file"
trap 'rm -f "$lock_file"' EXIT
echo "Proceeding — no overlapping run detected"Wrapping this around the health-check logic means a second, overlapping invocation exits immediately rather than racing the first one to should_alert.
Hands-On: Building And Testing v2
1. Set up a working directory.
mkdir -p ~/shell-course/healthcheck-project
cd ~/shell-course/healthcheck-project2. Write v2 and test the happy path.
cat > healthcheck-v2.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
readonly EXIT_INVALID_ARGS=2
readonly EXIT_UNHEALTHY=1
url="${1:-}"
max_time=5
max_attempts=3
alert_state_file="/tmp/shell-course-healthcheck.state"
min_alert_interval=300
log() {
local level="$1"
shift
local ts
ts=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$ts] [$level] $*" >&2
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
if [[ -z "$url" ]]; then
log_error "Usage: $0 <url>"
exit "$EXIT_INVALID_ARGS"
fi
should_alert() {
[[ ! -f "$alert_state_file" ]] && return 0
local last_alert
last_alert=$(cat "$alert_state_file")
local now
now=$(date +%s)
(( now - last_alert >= min_alert_interval ))
}
record_alert() { date +%s > "$alert_state_file"; }
attempt=1
response=""
while (( attempt <= max_attempts )); do
if response=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$max_time" "$url"); then
if [[ "$response" == "200" ]]; then
log_info "Attempt $attempt: OK ($response)"
break
else
log_warn "Attempt $attempt: unhealthy status $response"
fi
else
log_warn "Attempt $attempt: connection failed"
response=""
fi
(( attempt < max_attempts )) && sleep $(( attempt * 2 ))
(( attempt++ ))
done
if [[ "$response" == "200" ]]; then
log_info "Service healthy: $url"
rm -f "$alert_state_file"
exit 0
fi
if [[ -z "$response" ]]; then
log_error "Service unreachable after $max_attempts attempts: $url"
else
log_error "Service unhealthy after $max_attempts attempts: $url (last status: $response)"
fi
if should_alert; then
log_error "ALERT: $url is down"
record_alert
else
log_warn "Suppressing repeat alert (last alert within ${min_alert_interval}s)"
fi
exit "$EXIT_UNHEALTHY"
EOF
chmod +x healthcheck-v2.sh
./healthcheck-v2.sh https://example.com3. Trigger retries against an unreachable host.
./healthcheck-v2.sh https://this-domain-genuinely-does-not-exist.invalid4. Confirm alert rate-limiting on a second consecutive failure, then simulate the interval elapsing.
rm -f /tmp/shell-course-healthcheck.state
./healthcheck-v2.sh https://this-domain-genuinely-does-not-exist.invalid
./healthcheck-v2.sh https://this-domain-genuinely-does-not-exist.invalid
echo $(( $(date +%s) - 400 )) > /tmp/shell-course-healthcheck.state
./healthcheck-v2.sh https://this-domain-genuinely-does-not-exist.invalidConfirm the second run’s log shows the suppression message, while the third — after backdating the state file past min_alert_interval — alerts again.
5. Reproduce the concurrent-alert race, then fix it with a lockfile.
alert_state_file="/tmp/shell-course-demo-alert.state"
rm -f "$alert_state_file"
should_alert() {
[[ ! -f "$alert_state_file" ]] && return 0
local last_alert
last_alert=$(cat "$alert_state_file")
local now
now=$(date +%s)
(( now - last_alert >= 300 ))
}
record_alert() { date +%s > "$alert_state_file"; }
echo "--- broken: no locking ---"
if should_alert; then echo "Run A: would alert"; fi
if should_alert; then echo "Run B: would alert"; fi
record_alert
echo "--- fixed: with locking ---"
lock_file="/tmp/shell-course-demo-healthcheck.lock"
rm -f "$lock_file"
run_locked() {
local label="$1"
if [[ -f "$lock_file" ]]; then
existing_pid=$(cat "$lock_file")
if kill -0 "$existing_pid" 2>/dev/null; then
echo "$label: already running — skipping"
return
fi
fi
echo "$$" > "$lock_file"
echo "$label: proceeding"
rm -f "$lock_file"
}
run_locked "Run A"
run_locked "Run B"6. Clean up.
cd ~
rm -rf ~/shell-course/healthcheck-project
rm -f /tmp/shell-course-healthcheck.state /tmp/shell-course-demo-alert.state /tmp/shell-course-demo-healthcheck.lockWhat’s Left For Readers
curlwithout-Lflag does not follow redirects which means with this version of script, site likegoogle.comis ALERTed as down. Either add-Lflag or handle redirection codes such as:301,302.getoptscan be implemented along with shell parameters so support options like:max-attempt,wait-period, etc. directly from the command line.
Summary
You’ve now added a real timeout, retries with backoff, structured logging, rate-limited alerting with recovery-triggered reset, and — reaching back to the very first project’s locking pattern — closed a genuine concurrency gap this script would otherwise have carried into production. That completes both projects. The final chapter of this course moves to the capstone: a complete, original script built from a specification rather than evolved from an existing one, combining everything this course has covered from scratch.